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.
| Field | Type | Description |
|---|---|---|
| refund | action/refund/1.0 | The shopper chose a refund. Refund them, then acknowledge. Reimbursement follows. |
| replacement | action/replacement/1.0 | The shopper chose a replacement. Ship it, then acknowledge with the new order id. |
| claim-created | action/claim-created/1.0 | A shopper filed a claim. No action required. |
| claim-updated | action/claim-updated/1.0 | A claim changed state. No action required. |
| claim-solved | action/claim-solved/1.0 | A claim was closed. No action required. |
| Field | Type | Description |
|---|---|---|
| x-event-type | string | Topic and payload version, e.g. action/refund/1.0. |
| x-deduplication-id | string | Stable across all retries of one delivery. Dedupe on this, not on the timestamp. |
| x-request-id | string | Unique per attempt. For correlating with support. |
| x-test | boolean | true for simulated events. Take no real action. |
| x-arrive-signature | string | Base64 RS256 signature. Also sent as x-route-signature with the same value, so a Route integration works unchanged. |
| x-arrive-signature-alg / -kid / -timestamp | string | Always RS256; the key id; and an RFC3339 timestamp to the second. |
{
"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.
/v1/merchants/webhooks/keys{
"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.
message = alg + kid + timestamp + rawBody // exactly this order
signature = base64( RSASSA-PKCS1-v1_5( SHA-256( message ) ) )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.
/simulate/webhook/triggercurl --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.
/resolve/v1/air/acknowledgecurl --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"
}
]
}'| Field | Type | Description |
|---|---|---|
| idrequired | string | The id from the webhook payload you are acknowledging. |
| resultrequired | success | error | error answers 204, leaves the claim open, and may bring another webhook. |
| result_description | string | Why it failed, when result is error — e.g. "Out of stock". |
| actions[].action_reference_idrequired | string | Your reference — the replacement order id, or the refund confirmation id. |
| actions[].action_totalrequired | integer | Minor units. 12000 means $120.00. An amount with a decimal point is rejected. |
| actions[] | array | More 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.
| Field | Type | Description |
|---|---|---|
| Answer 2xx quickly | required | Any 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 duplicates | required | Assume 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 acting | required | Check the signature against the JWKS before you read the body. A refund instruction is worth forging. |
| Expect new fields | recommended | We 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.