Webhooks

Resolving claims.

When a claim is approved, Arrive asks you to make the shopper whole and reimburses you for it. These are the events that ask, how to prove they came from us, and how to tell us you acted.

Claim webhooks

When a claim is approved, Arrive asks you to make the shopper whole — refund them, or send a replacement — and then reimburses you for it. Webhooks are how we ask.

Two of the five topics require an acknowledgement. The other three are notifications you can ignore if you only want the action ones.

Topics
FieldTypeDescription
refundaction/refund/1.0The shopper chose a refund. Refund them, then acknowledge. Reimbursement follows.
replacementaction/replacement/1.0The shopper chose a replacement. Ship it, then acknowledge with the new order id.
claim-createdaction/claim-created/1.0A shopper filed a claim. No action required.
claim-updatedaction/claim-updated/1.0A claim changed state. No action required.
claim-solvedaction/claim-solved/1.0A claim was closed. No action required.
Headers on every delivery
FieldTypeDescription
x-event-typestringTopic and payload version, e.g. action/refund/1.0.
x-deduplication-idstringStable across all retries of one delivery. Dedupe on this, not on the timestamp.
x-request-idstringUnique per attempt. For correlating with support.
x-testbooleantrue for simulated events. Take no real action.
x-arrive-signaturestringBase64 RS256 signature. Also sent as x-route-signature with the same value, so a Route integration works unchanged.
x-arrive-signature-alg / -kid / -timestampstringAlways RS256; the key id; and an RFC3339 timestamp to the second.
Refund payload — note the amounts are in cents
{
      "id": "res_9f2c1a...",
      "api_version": "1.0",
      "merchant_id": "merch_2zta9l4uravokw42",
      "source_order_id": "11003271211",
      "source_order_number": "#9929",
      "claim_filed_at": "2026-08-10T12:00:00.000Z",
      "processed_at": "2026-08-11T09:00:00.000Z",
      "claim_type": "lost",
      "description": "Never turned up.",
      "user_email": "shopper@example.com",
      "tender_type": "original_payment_method",
      "currency_code": "USD",
      "total_covered": 12000,
      "subtotal": 12000,
      "shipping_cost": 0,
      "tax": 0,
      "line_items": [
        {
          "id": "0", "quantity": 2, "sku": "WI30301",
          "source_product_id": "213939", "name": "Basic Product 1",
          "currency_code": "USD",
          "price": 5000, "subtotal": 10000, "tax": 0, "discount": 0
        }
      ]
    }

Replacements may carry a different address

If the shopper gave one, the payload has alternate_shipping_details and you must ship there instead of to the original address. When they didn't, the key is absent entirely — not null.

Retries

Five attempts, backing off 1, 5, 15, then 60 minutes. Answer 2xx to stop them. We do not follow redirects, and an endpoint must be https on a public address.

Verifying webhooks

Every delivery is signed with RS256. Verifying is optional but strongly recommended — it is what proves a request asking you to refund a customer actually came from us.

GET/v1/merchants/webhooks/keys
Public keys, in JWKS form (RFC 7517). No authentication.
Response 200
{
      "keys": [
        {
          "alg": "RS256", "kty": "RSA", "use": "sig",
          "kid": "arrive-2026-08-a",
          "n": "tL2X-QqLKI8MCaecPq0qjGxuWAXtPyS7SnZmuQ...",
          "e": "AQAB"
        }
      ]
    }

Cache for up to a day. Pick the key whose kid matches the header. We rotate by publishing the new key here first and only signing with it a day later, so a cached copy always contains the key in use.

The message that was signed
message   = alg + kid + timestamp + rawBody      // exactly this order
    signature = base64( RSASSA-PKCS1-v1_5( SHA-256( message ) ) )
Node.js
import { createPublicKey, createVerify } from "node:crypto";

    async function verify(headers, rawBody) {
      const jwks = await fetch(
        "https://api.getarrive.app/v1/merchants/webhooks/keys"
      ).then((r) => r.json());          // cache this for up to a day

      const kid = headers["x-arrive-signature-kid"];
      const jwk = jwks.keys.find((k) => k.kid === kid);
      if (!jwk) return false;

      const key = createPublicKey({
        key: { kty: "RSA", n: jwk.n, e: jwk.e },
        format: "jwk",
      });

      const message =
        headers["x-arrive-signature-alg"] +
        kid +
        headers["x-arrive-signature-timestamp"] +
        rawBody;

      const v = createVerify("sha256");
      v.update(message, "utf8");
      v.end();
      return v.verify(key, headers["x-arrive-signature"], "base64");
    }

Verify the raw bytes

Hash the body exactly as it arrived. Parsing the JSON and re-serializing it changes key order and whitespace, and the signature will not match — this is the single most common reason verification fails.

POST/simulate/webhook/trigger
Send yourself a signed test event.
Request
curl --request POST \
      --url https://api.getarrive.app/simulate/webhook/trigger \
      --header 'Content-Type: application/json' \
      --header 'token: ak_live_9f2cA1kQ…' \
      --data '{ "topic": "replacement" }'

Sends a fixture — never a real claim — to your configured AIR endpoints, with x-test: true and test: true, and reports what each one answered. With no endpoint configured it returns the exact headers and body it would have sent, so you can replay them by hand.

Acknowledgements

After you refund or reship, tell us. This is what closes the claim and records what we owe you — a webhook we delivered proves nothing about whether you acted on it. The amount lands on your Finance → Reimbursements statement; settlement itself happens on your billing cycle, not at the moment you acknowledge.

POST/resolve/v1/air/acknowledge
Confirm you completed an action-required webhook.
Request
curl --request POST \
    --url https://api.getarrive.app/resolve/v1/air/acknowledge \
    --header 'Content-Type: application/json' \
    --user 'ak_live_9f2cA1kQ…:' \
    --data '{
      "id": "res_9f2c1a...",
      "result": "success",
      "actions": [
        {
          "tender_type": "replacement",
          "action_reference_id": "REORDER-4417",
          "action_total": 12000,
          "created_on": "2026-08-15T04:00:00Z",
          "user_email": "shopper@example.com"
        }
      ]
    }'
FieldTypeDescription
idrequiredstringThe id from the webhook payload you are acknowledging.
resultrequiredsuccess | errorerror answers 204, leaves the claim open, and may bring another webhook.
result_descriptionstringWhy it failed, when result is error — e.g. "Out of stock".
actions[].action_reference_idrequiredstringYour reference — the replacement order id, or the refund confirmation id.
actions[].action_totalrequiredintegerMinor units. 12000 means $120.00. An amount with a decimal point is rejected.
actions[]arrayMore than one entry for a split resolution (part refund, part replacement).

Authentication is HTTP Basic here

Unlike every other endpoint. Send your key as the username with an empty password — --user 'ak_live_…:'. The token header is accepted too if that is easier.

Acknowledging twice is safe

One acknowledgement per event id is recorded, ever. A repeat returns 200 with "duplicate": true and books nothing further, so a retry after a timeout cannot double your reimbursement.

What you are reimbursed

Your reimbursement is calculated from the approved claim under your agreement, not from the action_total you report. That figure is recorded as your account of what you spent, and a difference is worth raising with us rather than assuming either number is wrong.

Receiver best practices

Four things a webhook receiver has to get right. None are specific to Arrive — they are what makes any at-least-once delivery system safe to act on.

FieldTypeDescription
Answer 2xx quicklyrequiredAny 2xx means received and stored. Anything else is a failure and we retry. Persist the event and return — do not refund a customer before answering, or a slow payment provider turns into five delivery attempts.
Ignore duplicatesrequiredAssume you will see the same event twice. Deduplicate on x-deduplication-id, which is stable across every retry of one delivery, or on the payload id. Never dedupe on the timestamp — it changes per attempt.
Verify before actingrequiredCheck the signature against the JWKS before you read the body. A refund instruction is worth forging.
Expect new fieldsrecommendedWe add fields without changing the version. Ignore what you do not recognise rather than rejecting the payload.

Claim events update, action events do not

claim-created, claim-updated, and claim-solved all carry the same id for one claim — a later message is an update to the same thing, so upsert rather than append. refund and replacement get a fresh id per request, because each is acknowledged separately.