1"use strict";(globalThis.webpackChunkdocs=globalThis.webpackChunkdocs||[]).push([[8162],{6566:(e,n,t)=>{t.r(n),t.d(n,{assets:()=>o,contentTitle:()=>d,default:()=>h,frontMatter:()=>a,metadata:()=>s,toc:()=>l});const s=JSON.parse('{"id":"reference/webhooks","title":"Webhooks","description":"Abroad sends JSON webhooks to notify your application about transaction lifecycle changes.","source":"@site/docs/reference/webhooks.md","sourceDirName":"reference","slug":"/reference/webhooks","permalink":"/reference/webhooks","draft":false,"unlisted":false,"tags":[],"version":"current","sidebarPosition":2,"frontMatter":{"sidebar_position":2},"sidebar":"tutorialSidebar","previous":{"title":"API Reference","permalink":"/reference/api"},"next":{"title":"Supported Assets","permalink":"/resources/supported-assets"}}');var r=t(4848),i=t(8453);const a={sidebar_position:2},d="Webhooks",o={},l=[{value:"Events",id:"events",level:2},{value:"Refunds",id:"refunds",level:2},{value:"Payload structure",id:"payload-structure",level:2},{value:"Configuring the destination",id:"configuring-the-destination",level:2},{value:"Authenticating webhooks",id:"authenticating-webhooks",level:2},{value:"Handling & retries",id:"handling--retries",level:2},{value:"Delivery diagnostics",id:"delivery-diagnostics",level:2}];function c(e){const n={a:"a",code:"code",h1:"h1",h2:"h2",header:"header",li:"li",ol:"ol",p:"p",pre:"pre",strong:"strong",table:"table",tbody:"tbody",td:"td",th:"th",thead:"thead",tr:"tr",ul:"ul",...(0,i.R)(),...e.components};return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)(n.header,{children:(0,r.jsx)(n.h1,{id:"webhooks",children:"Webhooks"})}),"\n",(0,r.jsx)(n.p,{children:"Abroad sends JSON webhooks to notify your application about transaction lifecycle changes."}),"\n",(0,r.jsx)(n.h2,{id:"events",children:"Events"}),"\n",(0,r.jsxs)(n.table,{children:[(0,r.jsx)(n.thead,{children:(0,r.jsxs)(n.tr,{children:[(0,r.jsx)(n.th,{style:{textAlign:"left"},children:"Event name"}),(0,r.jsx)(n.th,{style:{textAlign:"left"},children:"When it is sent"}),(0,r.jsx)(n.th,{style:{textAlign:"left"},children:"Notes"})]})}),(0,r.jsxs)(n.tbody,{children:[(0,r.jsxs)(n.tr,{children:[(0,r.jsx)(n.td,{style:{textAlign:"left"},children:(0,r.jsx)(n.code,{children:"transaction.created"})}),(0,r.jsx)(n.td,{style:{textAlign:"left"},children:"A transaction was accepted and is waiting for funds, or the system detected an on-chain payment (including wrong-amount cases)."}),(0,r.jsxs)(n.td,{style:{textAlign:"left"},children:["May fire multiple times for the same ",(0,r.jsx)(n.code,{children:"id"})," as the transaction progresses."]})]}),(0,r.jsxs)(n.tr,{children:[(0,r.jsx)(n.td,{style:{textAlign:"left"},children:(0,r.jsx)(n.code,{children:"transaction.updated"})}),(0,r.jsxs)(n.td,{style:{textAlign:"left"},children:["The transaction status changed (processing, completed, failed, or expired), ",(0,r.jsx)(n.strong,{children:"or"})," a refund for it completed."]}),(0,r.jsx)(n.td,{style:{textAlign:"left"},children:"Used for payout, expiry, and refund updates."})]})]})]}),"\n",(0,r.jsx)(n.h2,{id:"refunds",children:"Refunds"}),"\n",(0,r.jsxs)(n.p,{children:["A refund does not have its own event or its own status \u2014 it arrives as a second ",(0,r.jsx)(n.code,{children:"transaction.updated"}),"."]}),"\n",(0,r.jsxs)(n.p,{children:["When a transaction fails, expires, or is paid in the wrong amount, you first get a ",(0,r.jsx)(n.code,{children:"transaction.updated"})," carrying that status. Abroad then returns the funds, and once the return settles you get ",(0,r.jsx)(n.strong,{children:"another"})," ",(0,r.jsx)(n.code,{children:"transaction.updated"})," for the same ",(0,r.jsx)(n.code,{children:"id"}),". The status is unchanged between the two; what changes is ",(0,r.jsx)(n.code,{children:"refundOnChainId"}),", which is ",(0,r.jsx)(n.code,{children:"null"})," in the first and carries the refund's identifier in the second:"]}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsx)(n.li,{children:"crypto sent back to its sender \u2014 the refund's on-chain transaction hash or signature;"}),"\n",(0,r.jsx)(n.li,{children:"a fiat deposit returned to its payer (onramp) \u2014 the provider's refund id."}),"\n"]}),"\n",(0,r.jsxs)(n.p,{children:["So treat a non-null ",(0,r.jsx)(n.code,{children:"refundOnChainId"}),' as "the money is back", not the status field. Handlers must already be idempotent for repeated events on one ',(0,r.jsx)(n.code,{children:"id"})," \u2014 this is one of those repeats."]}),"\n",(0,r.jsx)(n.h2,{id:"payload-structure",children:"Payload structure"}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-json",children:'{\n "event": "transaction.updated",\n "data": {\n "id": "f4a96c4c-4d1e-4ab2-a6ec-2e1b5070c5db",\n "status": "PROCESSING_PAYMENT",\n "quoteId": "550e8400-e29b-41d4-a716-446655440000",\n "accountNumber": "3001234567",\n "onChainId": "2bebb7...",\n "refundOnChainId": null,\n "taxId": "123456789",\n "externalId": null,\n "partnerUserId": "10d9f483-d048-4e55-a75b-e7ebd475d737"\n }\n}\n'})}),"\n",(0,r.jsxs)(n.p,{children:[(0,r.jsx)(n.code,{children:"data"})," mirrors the transaction record and may include nested quote fields on some events. Internal routing fields are stripped from every payload: ",(0,r.jsx)(n.code,{children:"bankCode"})," and ",(0,r.jsx)(n.code,{children:"origin"})," are never sent."]}),"\n",(0,r.jsxs)(n.p,{children:["To derive the memo for reconciliation, compute ",(0,r.jsx)(n.code,{children:"transaction_reference"})," from ",(0,r.jsx)(n.code,{children:"id"})," (Base64) or call ",(0,r.jsx)(n.code,{children:"GET /transaction/{id}"}),".\nFor Stellar and Celo payments, ",(0,r.jsx)(n.code,{children:"onChainId"})," is the transaction hash. For Solana payments, it is the transaction signature."]}),"\n",(0,r.jsx)(n.h2,{id:"configuring-the-destination",children:"Configuring the destination"}),"\n",(0,r.jsxs)(n.p,{children:["Administrators manage the webhook de
1stination and its signing secret from ",(0,r.jsx)(n.strong,{children:"Integration"})," in the partner portal. The flow is staged rather than immediate:"]}),"\n",(0,r.jsxs)(n.ol,{children:["\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.strong,{children:"Stage"})," a draft URL."]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.strong,{children:"Test"})," the draft \u2014 Abroad sends a test delivery so you can confirm your receiver accepts it before it takes live traffic."]}),"\n",(0,r.jsxs)(n.li,{children:[(0,r.jsx)(n.strong,{children:"Activate"})," the draft to promote it to the live destination, or discard it."]}),"\n"]}),"\n",(0,r.jsx)(n.p,{children:"You can rotate the signing secret independently of the URL. Both the secret and the URL change take effect without redeploying anything on the Abroad side."}),"\n",(0,r.jsx)(n.h2,{id:"authenticating-webhooks",children:"Authenticating webhooks"}),"\n",(0,r.jsxs)(n.p,{children:["Abroad sends an ",(0,r.jsx)(n.code,{children:"X-Abroad-Webhook-Secret"})," header. Verify it against your expected value before processing the payload."]}),"\n",(0,r.jsx)(n.pre,{children:(0,r.jsx)(n.code,{className:"language-javascript",children:"app.post('/webhooks/abroad', express.json(), (req, res) => {\n const secret = process.env.ABROAD_WEBHOOK_SECRET;\n if (req.header('X-Abroad-Webhook-Secret') !== secret) {\n return res.status(401).send('Invalid signature');\n }\n\n const { event, data } = req.body;\n // TODO: handle event + data\n\n res.sendStatus(200); // Acknowledge quickly\n});\n"})}),"\n",(0,r.jsx)(n.h2,{id:"handling--retries",children:"Handling & retries"}),"\n",(0,r.jsx)(n.p,{children:"Deliveries go through a durable outbox, so a failed delivery is retried automatically:"}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsxs)(n.li,{children:["Up to ",(0,r.jsx)(n.strong,{children:"5 attempts"})," per event."]}),"\n",(0,r.jsxs)(n.li,{children:["Exponential backoff between attempts (roughly 2s, 4s, 8s, 16s\u2026), capped at ",(0,r.jsx)(n.strong,{children:"60 seconds"}),"."]}),"\n",(0,r.jsx)(n.li,{children:"Only retryable failures are re-attempted. A rejected target or an unavailable signing credential fails immediately."}),"\n"]}),"\n",(0,r.jsx)(n.p,{children:"Practical guidance:"}),"\n",(0,r.jsxs)(n.ul,{children:["\n",(0,r.jsxs)(n.li,{children:["Respond with ",(0,r.jsx)(n.code,{children:"2xx"})," as soon as you validate the secret; do downstream work asynchronously."]}),"\n",(0,r.jsxs)(n.li,{children:["Make handlers ",(0,r.jsx)(n.strong,{children:"idempotent"})," \u2014 retries and lifecycle transitions mean the same ",(0,r.jsx)(n.code,{children:"id"})," can arrive more than once. Store the latest ",(0,r.jsx)(n.code,{children:"status"})," per transaction and ignore stale or duplicate events."]}),"\n",(0,r.jsxs)(n.li,{children:["Abroad follows no redirects and caps the response body it will read, so answer directly with a small ",(0,r.jsx)(n.code,{children:"2xx"})," body."]}),"\n",(0,r.jsxs)(n.li,{children:["If your receiver was down past the retry budget, reconcile with ",(0,r.jsx)(n.code,{children:"GET /transaction/{id}"})," and ",(0,r.jsx)(n.code,{children:"GET /transactions/list"}),"."]}),"\n"]}),"\n",(0,r.jsx)(n.h2,{id:"delivery-diagnostics",children:"Delivery diagnostics"}),"\n",(0,r.jsxs)(n.p,{children:["Delivery health \u2014 attempt counts, HTTP status, and duration per event \u2014 is visible in the partner portal, and an administrator can redeliver a failed event from there. A connected AI assistant with the ",(0,r.jsx)(n.code,{children:"webhooks:read"})," permission can read aggregate delivery health (never payloads, URLs, or secrets); see ",(0,r.jsx)(n.a,{href:"../ai-integration",children:"Connect Abroad to AI"}),"."]})]})}function h(e={}){const{wrapper:n}={...(0,i.R)(),...e.components};return n?(0,r.jsx)(n,{...e,children:(0,r.jsx)(c,{...e})}):c(e)}}}]);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.