Skip to content

Webhooks

Webhooks enable real-time event notifications, allowing you to integrate external systems with Exa.

A default endpoint can be configured and optionally an endpoint for each of the 5 event types:

  • Transaction created
  • Transaction updated
  • Transaction completed
  • User updated
  • Card updated

Each webhook request is signed using an HMAC SHA256 signature, based on the exact JSON payload sent in the body. This signature is included in the Signature HTTP header of the request.

You can verify webhook authenticity by computing the HMAC signature and comparing it to the Signature header included in the webhook request.

Example: Verifying a webhook signature (Node.js)

import { createHmac } from "crypto";
const signature = createHmac("sha256", <YOUR_API_KEY>)
.update(<REQUEST_BODY_AS_JSON_STRING>) // JSON.stringify(payload)
.digest("hex");

Ensure that the computed signature matches the Signature header received in the webhook request before processing the payload.

An exponential backoff with 20 retries and 60 second timeout is used. Retries occur if the request returns an http status code other than 2xx or times out.

Retry CountDelay (ms)Delay (seconds)Delay (minutes)
05000.5s-
11,0001s-
22,0002s-
34,0004s-
48,0008s-
516,00016s-
1632,768,00032768s~546.1min
1765,536,00065536s~1092.3min
18131,072,000131072s~2184.5min
19262,144,000262144s~4369.1min

There are 5 different types of flow that uses different events which details are in the Event reference section:

  • Purchase lifecycle with settlement
  • Partial capture
  • Over capture
  • Force capture
  • Refund

This example demonstrates a complete transaction lifecycle through webhook notifications, showing how a transaction progresses from initial transaction created to final settlement with an amount adjustment.

sequenceDiagram
    participant Merchant
    participant Exa
    participant Blockchain
    participant Uphold


    Merchant->>Exa: auth request ($100)
    activate Exa

    Exa->>Blockchain: simulate collect ($100)
    activate Blockchain
    Note over Blockchain: total collect simulation ($100)
    Blockchain-->>Exa: simulation OK
    deactivate Blockchain
    Exa-->>Merchant: auth approved

    Exa->>Blockchain: collect ($100)
    activate Blockchain
    Note over Blockchain: total collect ($100)
    Blockchain-->>Exa: Transaction hash
    deactivate Blockchain

    deactivate Exa

    Exa->>Uphold: transaction.created webhook ($100)
    activate Uphold
    Uphold-->>Exa: webhook acknowledged
    deactivate Uphold

    Note over Merchant,Uphold: Time passes (usually same day)


    Merchant->>Exa: reversal request (-20)
    activate Exa

    Exa->>Blockchain: Refund ($20)
    activate Blockchain
    Note over Blockchain: Refund
    Blockchain-->>Exa: Transaction hash
    deactivate Blockchain
    Exa-->>Merchant: reversal approved

    Exa->>Uphold: webhook transaction.updated (-20)
    activate Uphold
    Uphold-->>Exa: webhook acknowledged
    deactivate Uphold

    deactivate Exa
    Note over Merchant,Uphold: Time passes (usually 3 business days)

    Exa->>Uphold: webhook transaction.completed (80)
    activate Uphold
    Uphold-->>Exa: webhook acknowledged
    deactivate Uphold

Transaction authorized and created with timestamp, for $100.00 amount.

{
"id": "99493687-78c1-4018-8831-d8b1f66f58e2",
"timestamp": "2025-08-13T14:36:04.586Z",
"resource": "transaction",
"action": "created",
"receipt": {
"blockNumber": 97,
"transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db",
},
"body": {
"id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000",
"type": "spend",
"spend": {
"amount": 10000,
"currency": "usd",
"cardId": "e874583f-47d9-4211-8ea6-3b92e450821b",
"localAmount": 10000,
"localCurrency": "usd",
"merchantCity": "",
"merchantCountry": "",
"merchantCategory": "-",
"merchantName": "Test",
"authorizedAt": "2025-06-25T15:24:11.337Z",
"authorizedAmount": 10000,
"status": "pending"
}
}
}

Amount adjusted from $100.00 to $80.00 with status “reversed” and authorizationUpdateAmount of -$20.00 Note that this is a reversal, 1 of the 3 types of refunds.

{
"id": "e7b2853e-4bb7-4428-8dc2-27e604766dfa",
"timestamp": "2025-08-12T20:08:37.707Z",
"resource": "transaction",
"action": "updated",
"receipt": {
"blockNumber": 98,
"transactionHash": "0x8c6ef90db7901c43018b3b079ac5ccf84e9c1eb2aaf0fd5f1f8b3e2b97d25fa3",
},
"body": {
"id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000",
"type": "spend",
"spend": {
"amount": 8000,
"currency": "usd",
"cardId": "e874583f-47d9-4211-8ea6-3b92e450821b",
"localAmount": 8000,
"localCurrency": "usd",
"merchantCity": "",
"merchantCountry": "",
"merchantCategory": "-",
"merchantName": "Test",
"authorizedAt": "2025-06-25T15:24:11.337Z",
"authorizedAmount": 8000,
"authorizationUpdateAmount": -2000,
"status": "reversed",
"enrichedMerchantName": "Test",
"enrichedMerchantCategory": "Education"
}
}
}

Final settlement at $80.00 with status “completed”.

{
"id": "662eb701-f9ac-4baa-9f86-b341a730c98a",
"timestamp": "2025-08-12T20:23:20.662Z",
"resource": "transaction",
"action": "completed",
"body": {
"id": "bdc87700-bf6d-4d7d-ac29-3effb06e3000",
"type": "spend",
"spend": {
"amount": 8000,
"currency": "usd",
"cardId": "e874583f-47d9-4211-8ea6-3b92e450821b",
"localAmount": 8000,
"localCurrency": "usd",
"merchantCity": "",
"merchantCountry": "",
"merchantCategory": "",
"merchantName": "Test",
"authorizedAt": "2025-06-25T15:24:11.337Z",
"authorizedAmount": 8000,
"status": "completed",
"enrichedMerchantName": "Test",
"enrichedMerchantCategory": "Education"
}
}
}

In a partial capture, the merchant settles for less than the authorized amount. After receiving the transaction completed webhook, the over authorized and captured funds are released to the user. This flow is common in restaurants, where the final charge may be lower than the original authorization after accounting for tips.

Transaction authorized and created with timestamp for $100.00 amount.

{
"id": "99493687-78c1-4018-8831-d8b1f66f58e2",
"timestamp": "2025-08-13T16:37:08.862Z",
"resource": "transaction",
"action": "created",
"receipt": {
"blockNumber": 108,
"transactionHash": "0x59be2972d1094e6abc14f595b71ed4e9e6ec4e2cd8d61e292f6debcba37e19b4",
},
"body": {
"id": "be67eeb7-294a-42d9-b337-77bfad198aad",
"type": "spend",
"spend": {
"amount": 10000,
"currency": "usd",
"cardId": "827c3893-d7c8-46d4-a518-744b016555bc",
"localAmount": 10000,
"localCurrency": "usd",
"merchantCity": "",
"merchantCountry": "",
"merchantCategory": "-",
"merchantName": "Test",
"authorizedAt": "2025-06-25T15:24:11.337Z",
"authorizedAmount": 10000,
"status": "pending"
}
}
}

Final settlement at $90.00 with status “completed” and timestamp. The final amount is $90 and previously $100 was authorized and captured to the user so $10 is refunded. This is one of the 3 types of refunds.

{
"id": "a79306b2-bbbc-4511-9e58-ca9fbc9a2d9a",
"timestamp": "2025-08-13T16:42:28.955Z",
"resource": "transaction",
"action": "completed",
"receipt": {
"blockNumber": 109,
"transactionHash": "0xd3b27341a97f4621865d896713a82be4099c5e0ad18782fb134fa33a77bba937",
},
"body": {
"id": "be67eeb7-294a-42d9-b337-77bfad198aad",
"type": "spend",
"spend": {
"amount": 9000,
"currency": "usd",
"cardId": "827c3893-d7c8-46d4-a518-744b016555bc",
"localAmount": 9000,
"localCurrency": "usd",
"merchantCity": "New York",
"merchantCountry": "US",
"merchantCategory": "5511",
"merchantName": "PartialCapture Example",
"authorizedAt": "2025-07-03T18:40:28.024Z",
"authorizedAmount": 10000,
"status": "completed",
"enrichedMerchantName": "Partial capture Example",
"enrichedMerchantCategory": "Business - Software"
}
}
}

In an over capture, the merchant settles for more than the originally authorized amount. This flow is typically used in scenarios that involve tips or additional surcharges, such as dining or hospitality. Certain industries, like restaurants and bars, are allowed to settle for more than the authorized amount—typically up to 20%—to accommodate tips and similar charges.

Transaction authorized and created with timestamp for $100.00 amount.

{
"id": "9d96c8c9-d10f-4d3a-90b9-978eca13ae2a",
"timestamp": "2025-08-13T16:53:21.455Z",
"resource": "transaction",
"action": "created",
"receipt": {
"blockNumber": 300,
"transactionHash": "0x7faf9d14fde333a946c27f9e173c2d640ef3b4fbafc7e75d2a8a4b8743efb001",
},
"body": {
"id": "be67eeb7-294a-42d9-b337-77bfad198aad",
"type": "spend",
"spend": {
"amount": 10000,
"currency": "usd",
"cardId": "827c3893-d7c8-46d4-a518-744b016555bc",
"localAmount": 10000,
"localCurrency": "usd",
"merchantCity": "New York",
"merchantCountry": "US",
"merchantCategory": "5812 - Restaurant",
"merchantName": "OverCapture Example",
"authorizedAt": "2025-07-03T18:53:49.958Z",
"authorizedAmount": 10000,
"status": "pending"
}
}
}

Final settlement at $110.00 with status “completed” and timestamp. Note that the final amount is 110 but 100 was authorized and captured so capturing an extra $10 to the user is needed.

{
"id": "593b0673-82ba-457b-afce-1cbd725f9e3c",
"timestamp": "2025-08-13T16:55:11.934Z",
"resource": "transaction",
"action": "completed",
"receipt": {
"blockNumber": 499,
"transactionHash": "0x2d3a8b61a94f5f36b0d64f3e6a7c5e1bb7eeba6004cd3f1dc7c02b265aec7b02",
},
"body": {
"id": "be67eeb7-294a-42d9-b337-77bfad198aad",
"type": "spend",
"spend": {
"amount": 11000,
"currency": "usd",
"cardId": "827c3893-d7c8-46d4-a518-744b016555bc",
"localAmount": 11000,
"localCurrency": "usd",
"merchantCity": "New York",
"merchantCountry": "US",
"merchantCategory": "Restaurant",
"merchantName": "OverCapture Example",
"authorizedAt": "2025-07-03T18:53:49.958Z",
"authorizedAmount": 10000,
"status": "completed",
"enrichedMerchantName": "Over Capture Example",
"enrichedMerchantCategory": "Restaurants"
}
}
}

A force capture occurs when a merchant settles a transaction without prior authorization. These transactions bypass the authorization phase and proceed directly to settlement. This flow is typically used in offline scenarios, such as in-flight purchases where the merchant does not have internet access.

{
"id": "593b0673-82ba-457b-afce-1cbd725f9e3c",
"timestamp": "2025-08-13T17:00:08.061Z",
"resource": "transaction",
"action": "completed",
"receipt": {
"blockNumber": 97,
"transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db",
},
"body": {
"id": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card",
"type": "spend",
"spend": {
"amount": 11000,
"currency": "usd",
"cardId": "0x8eFc15407B97a28a537d105AB28fB442324CC2ee-card",
"localAmount": 11000,
"localCurrency": "usd",
"merchantCity": "New York",
"merchantCountry": "US",
"merchantCategory": "Restaurant",
"merchantName": "OverCapture Example",
"authorizedAt": "2025-07-03T18:53:49.958Z",
"authorizedAmount": 10000,
"status": "completed",
"enrichedMerchantName": "Over Capture Example",
"enrichedMerchantCategory": "Restaurants"
}
}
}

Refunds are treated as negative transactions and may or may not reference the original transaction completed. Unlike reversals, refunds can be initiated independently of the original transaction and may occur well after the initial settlement.

The webhook is only for informational purpose, Exa does not return funds to the user with this event, is just to notify that a proper refund is coming and do sanity checks.

{
"id": "a2684ac7-13bc-4b0e-ab4d-5a2ac036218a",
"timestamp": "2025-08-13T17:08:50.609Z",
"resource": "transaction",
"action": "created",
"body": {
"id": "be67eeb7-294a-42d9-b337-77bfad198aad",
"type": "spend",
"spend": {
"amount": -10000,
"currency": "usd",
"cardId": "827c3893-d7c8-46d4-a518-744b016555bc",
"localAmount": -10000,
"localCurrency": "usd",
"merchantCity": "New York",
"merchantCountry": "US",
"merchantCategory": "5641 - Children's and Infant's Wear Store",
"merchantName": "Test Refund",
"authorizedAt": "2025-07-03T19:52:59.806Z",
"authorizedAmount": -10000,
"status": "pending"
}
}
}

Final settlement of -$100.00 with status “completed” and timestamp. Refund $100 to the user.

{
"id": "77474a56-51eb-4918-b09e-73cf20077b1b",
"timestamp": "2025-08-13T17:12:48.858Z",
"resource": "transaction",
"action": "completed",
"receipt": {
"blockNumber": 97,
"transactionHash": "0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db",
},
"body": {
"id": "be67eeb7-294a-42d9-b337-77bfad198aad",
"type": "spend",
"spend": {
"amount": -10000,
"currency": "usd",
"cardId": "827c3893-d7c8-46d4-a518-744b016555bc",
"localAmount": -10000,
"localCurrency": "usd",
"merchantCity": "New York",
"merchantCountry": "US",
"merchantCategory": "Children's and Infant's Wear Store",
"merchantName": "Test Refund",
"authorizedAt": "2025-07-03T19:52:59.806Z",
"authorizedAmount": -10000,
"status": "completed",
"enrichedMerchantName": "Test Refund",
"enrichedMerchantCategory": "Refunds - Insufficient Funds"
}
}
}

There are 3 types of operations that return funds to the user: reversal, partial capture, and refund.

This occurs when the user calls an uber, for example. Authorizes $30 but then the travel is cancelled, so exa instantly return the funds to the user in a $30 reversal. This happens before the settlement and can happen many times. Timing: reversals are usually during the same day.

This happens when a transaction enters a terminal state, which means no more reversals or other event types are allowed. This is the last event. If the authorized amount is higher than the final amount, funds need to be returned to the user. This looks pretty much like a reversal but also signals to the user that no more assets will be requested or returned as part of the purchase flow. Timing: usually 2 or 3 business days after swiping the card.

Refunds come after the purchase enters a terminal state and could be associated with the purchase or not. That is not guaranteed, but if it is not the same, using the merchant name to link is suggested. Timing: more than a week.

OperationDisplayTime
reversalpurchase detailssame day
partialpurchase details2 or 3 business days
refundsactivityweeks

The transaction created webhook is sent when the transaction flow is created, whether it has been authorized or declined. You must persist this information. This event initiates the purchase lifecycle in case of pending, then could exist many intermediate state changes done by transaction update event and finally the transaction complete event sets the purchase in terminal state. No more events coming except of a refund which transaction id could be the same as the original purchase or not. The onchain receipt will be present only if a onchain transaction is necessary.

fieldtypedescriptionexample
idstringwebhookId and always the same when retry372d1a76-8a57-403e-a7f3-ac3231be144c
timestampstringTime when sent the event. Always the same when retry2025-08-06T20:29:23.870Z
resource”transaction”transaction
action”created”created
receipt?.blockNumbernumberonchain transaction block number97
receipt?.transactionHashstringTransaction hash0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db
body.idstringTransaction id. Is the same for many events in the life cycle of the purchasef1083e93-afd5-4271-85c6-dd47099e9746
body.type”spend”spend
body.spend.amountintegerAmount of the purchase in USD in cents. 1 USD = 100100
body.spend.currencystringAlways in usdusd
body.spend.cardIdstring47c3c3b3-b197-4a97-ace3-901a6ad7cf61
body.spend.localAmountintegerPurchase amount in local currency100
body.spend.localCurrencystringThe local currencyusd, eur, ars
body.spend.merchantCity?stringThe merchant city”San Francisco”
body.spend.merchantCountry?stringThe merchant country”US”
body.spend.merchantCategory?stringThe merchant category”5814 - Quick Payment Service-Fast Food Restaurants”
body.spend.merchantCategoryCode?stringThe merchant category code”5599”
body.spend.merchantNamestringThe merchant nameSQ *BLUE BOTTLE COFFEE
body.spend.merchantId?stringId of the merchant550e8400-e29b-41d4-a716-446655440000
body.spend.authorizedAtstringTime when purchase was authorized in ISO 86012025-08-06T20:29:23.288Z
body.spend.authorizedAmountintegerThe authorized amount100
body.spend.status”pending” | “declined”Can be pending or declined. In case of declined, the field declinedReason has the reasonpending
body.spend.declinedReason?stringDecline messagewebhook declined
body.spend.exchangeRate?numberPresent when currency differs from localCurrency. The exchange rate applied to the transaction1.1806900825

This webhook is sent whenever a transaction is updated. Note that the transaction may not have been created before this update. Triggered for events such as incremental authorizations or reversals (a type of refund). The receipt exist only if an onchain transaction is necessary.

fieldtypedescriptionexample
idstringwebhook id and always the same when retrye972a2b0-a990-47af-b460-500ff75fbf65
timestampstringtime when the event was triggered in ISO 8601 format2025-08-11T15:30:39.939Z
resource”transaction”transaction
action”updated”updated
receipt?.blockNumbernumberonchain transaction block number97
receipt?.transactionHashstringTransaction hash0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db
body.idstringtransaction id. the same in the life cycle of the purchase96fbeb61-b4b0-59ab-93e0-2f2afce7637c
body.type”spend”spend
body.spend.amountnumberamount in usd authorized2499
body.spend.currencystringalways dollarsusd
body.spend.cardIdstringcard identifiere874583f-47d9-4211-8ea6-3b92e450821b
body.spend.localAmountnumberamount in local currency authorized2499
body.spend.localCurrencystringcurrency of the purchaseusd
body.spend.merchantCity?stringcity of the merchantSAN FRANCISCO
body.spend.merchantCountry?stringcountry of the merchantUS
body.spend.merchantCategory?stringcategory of the merchant4121 - Taxicabs and Limousines
body.spend.merchantCategoryCode?stringThe merchant category code”5599”
body.spend.merchantId?stringId of the merchant550e8400-e29b-41d4-a716-446655440000
body.spend.merchantNamestringname of the merchantUBER *TRIP
body.spend.authorizedAtstringtime when purchase was authorized in ISO 86012025-08-10T04:28:39.547Z
body.spend.authorizedAmountnumberamount authorized2499
body.spend.authorizationUpdateAmountnumberamount difference authorized. it can be positive in case of status pending or negative if is a reversal. will be declined if was not possible to authorize the increment or decrement of the authorization726
body.spend.status”pending” | “reversed” | “declined”current status of the transactionpending
body.spend.enrichedMerchantIcon?stringurl of the enriched merchant iconhttps://storage.googleapis.com/heron-merchant-assets/icons/mrc_Syjxck7oqeRQxzHAjc9XrD.png
body.spend.enrichedMerchantName?stringname of the enriched merchantUber
body.spend.enrichedMerchantCategory?stringcategory of the enriched merchantTransport - Rides

This webhook is sent whenever a transaction reaches a final state. Note that the transaction may not have been created before this update. The receipt exist only if an onchain transaction is necessary.

fieldtypedescriptionexample
idstringwebhook id and always the same when retry662eb701-f9ac-4baa-9f86-b341a730c6dc
timestampstringtime when the event was triggered in ISO 8601 format2025-08-12T18:29:20.499Z
resource”transaction”transaction
action”completed”completed
receipt?.blockNumbernumberonchain transaction block number.97
receipt?.transactionHashstringTransaction hash0xb0af3b716fc47e18519a74858690a8b428d9a5ac9c5537d08314443a5b1501db
body.idstringIs the Transaction id and is the same in the life cycle of the purchase. With refunds could be different from the original purchase.96fbeb61-b4b0-59ab-93e0-2f2afce7637c
body.type”spend”spend
body.spend.amountnumberfinal settled amount in usd1041
body.spend.currencystringalways dollarsusd
body.spend.cardIdstringcard identifiere874583f-47d9-4211-8ea6-3b92e450821b
body.spend.localAmountnumberfinal settled amount in local currency1270000
body.spend.localCurrencystringcurrency of the purchasears
body.spend.merchantCity?stringcity of the merchantCAP.FEDERAL
body.spend.merchantCountry?stringcountry of the merchantAR
body.spend.merchantCategory?stringcategory of the merchantRecreation Services
body.spend.merchantCategoryCode?stringThe merchant category code”5599”
body.spend.merchantNamestringname of the merchantJOCKEY CLUB
body.spend.merchantId?stringId of the merchant550e8400-e29b-41d4-a716-446655440000
body.spend.authorizedAtstringtime when purchase was authorized in ISO 86012025-08-08T17:55:14.312Z
body.spend.authorizedAmountnumberoriginal authorized amount1035
body.spend.status”completed”final status of the transactioncompleted
body.spend.enrichedMerchantIcon?stringurl of the enriched merchant iconhttps://storage.googleapis.com/heron-merchant-assets/icons/mrc_BqxmeYFvJmCprexvXUDF7h.png
body.spend.enrichedMerchantName?stringname of the enriched merchantJockey
body.spend.enrichedMerchantCategory?stringcategory of the enriched merchantShopping
body.spend.exchangeRate?numberPresent when currency differs from localCurrency. The exchange rate applied to the transaction1.1806900825

This webhook is sent whenever a user’s compliance status is updated. No response is required.

fieldtypedescriptionexample
idstringwebhook id and always the same when retrybdc87700-bf6d-4d7d-ac29-3effb06e3000
timestampstringtime when the event was triggered in ISO 8601 format2025-08-12T19:16:56.709Z
resource”user”user
action”updated”updated
body.credentialIdstringcredential id0xE18847D2f02cE2800C07c5b42e66c819eC78d35f
body.applicationReasonstringreason for application statusCOMPROMISED_PERSONS, PEP
body.applicationStatus”approved” | “pending” | “needsInformation” | “needsVerification” | “manualReview” | “denied” | “locked” | “canceled”current status of the applicationpending
body.isActivebooleanwhether the user is activetrue

This webhook is currently triggered when a user adds their card to a digital wallet.

fieldtypedescriptionexample
idstringwebhook id and always the same when retry31740000-bd68-40c8-a400-5a0131f58800
timestampstringtime when the event was triggered in ISO 8601 format2025-08-12T18:47:33.687Z
resource”card”card
action”updated”updated
body.idstringcard identifiere874583f-47d9-4211-8ea6-3b92e450821b
body.last4stringlast 4 digits of the card7392
body.limit.amountnumberspending limit amount1000000
body.limit.frequency”per24HourPeriod” | “per7DayPeriod” | “per30DayPeriod” | “perYearPeriod”frequency of the spending limitper7DayPeriod
body.status”ACTIVE” | “FROZEN” | “DELETED” | “INACTIVE”current status of the cardACTIVE
body.tokenWallets[“Apple”] | [“Google Pay”] | undefinedarray of token wallets[“Apple”]
#BreakPrevious behaviorNew behaviorClient suggestion
1GET /webhook/:name returns 404 when the webhook (or source) is missingNot available — only the collection endpoint existedThe collection GET /webhook still returns 200 {} when the org has no source; only the single GET /webhook/:name returns 404 { "code": "not found" } when the source or named webhook is missingFor single reads via :name, treat 404 as “no such webhook”; the collection endpoint stays 200 {} when empty
2New path variant GET /webhook/:nameNot available — only collection endpointReturns the single matching webhook, or 404 { "code": "not found" }Switch single-webhook reads from GET /webhook + client-side lookup to GET /webhook/:name
3POST /webhook no longer overwritesReusing a name overwrote the existing webhook (including url/event maps) and returned 200Duplicate name returns 409 { "code": "name conflict" }Replace the overwrite flow with PATCH /webhook/:name (or delete-then-create if you must keep the old call shape)
4POST /webhook success status 200201200 OK on create or update201 Created on create onlyAccept 201 (or >=200 <300) instead of strict 200 equality
5POST /webhook response shape gains name{ url, transaction?, card?, user?, secret }{ name, url, transaction?, card?, user?, secret }Not breaking if clients ignored unknown fields, but valibot/zod consumers should add name to the schema
6POST /webhook accepts name via pathRequired name field in bodyName accepted as path param (POST /webhook/:name) or body field; path wins when both present; missing both → 400 { "code": "invalid name" }Prefer the path form for new code; the body form still works for backwards compatibility
7name must match slug regex ^[a-z0-9-]{1,64}$Any non-empty string acceptedInvalid name → 400 { "code": "invalid name" } (also on GET/PATCH/DELETE /:name)Normalize names client-side to lowercase, digits, hyphens, ≤64 chars
8URL validation rejects non-https and private/loopback hostsUsed the isValid helper (lenient — allowed http and any reachable host)Rejects: non-https:, unresolvable hosts, and addresses in 127.0.0.0/8, 10/8, 172.16/12, 192.168/16, 169.254/16, 0.0.0.0, ::1, fc00::/7, fe80::/10, 2001:db8::/32400 { "code": "invalid url" } (the handler catches the validation error and returns only code; no message array is sent despite the OpenAPI schema allowing one)Use only public https endpoints; update local-dev tunnels (ngrok et al. — must resolve to a public IP)
9New PATCH /webhook/:name endpointNot availablePartial update; omitted fields preserved; null on a per-event URL clears it; the parent group is dropped when empty; 404 if the webhook is missing; secret preserved and not returnedUse PATCH for any field change; do not re-POST the same name
10DELETE /webhook body → DELETE /webhook/:name pathName passed as a { "name": "..." } JSON bodyName is a required path param; body ignoredMove the name to the URL; remove the JSON body and content-type: application/json from delete requests
11DELETE no longer silent on missing nameUnknown name returned 200 { "code": "ok" }404 { "code": "not found" }Stop relying on an idempotent “ok” — treat 404 as already-deleted if that’s acceptable
12DELETE of the last webhook drops the source rowSource row preserved with an empty webhooks mapWhen the last webhook is deleted, the entire sources row is deletedThe collection GET /webhook then returns 200 {} (not 404); a subsequent GET /webhook/:name returns 404 not found — see break #1
13Secret length doubled (16 → 32 bytes hex)32-char hex string64-char hex stringWiden any fixed-length column / validation regex storing the secret
14New source type is "integrator" (was "uphold")WebhookConfig.type === "uphold" when the first webhook was createdWebhookConfig.type === "integrator" for new sources; existing rows untouchedOnly relevant if a client reads/parses the raw sources.config.type — accept both values
15New permission webhook:updateRoles admin/owner had ["create","delete","read"]Roles now also have "update"; PATCH requires itRe-issue better-auth role assignments if you cache them; the member role is unchanged
16POST body URL fields now strictly validated as URLs at the API boundaryBaseWebhook.url etc. were plain string() (validation happened elsewhere)Every URL field uses pipe(string(), url()) — malformed strings fail 400 before URL-reachability checksEnsure transaction.{created,updated,completed}, card.updated, user.updated, and top-level url are well-formed absolute URLs

Break #1 — collection stays {}, single :name is 404

Section titled “Break #1 — collection stays {}, single :name is 404”
# collection endpoint — empty org still returns 200 {}
GET /webhook
HTTP/1.1 200 OK
{}
# single-webhook endpoint — missing source or name returns 404
GET /webhook/main
HTTP/1.1 404 Not Found
{ "code": "not found" }

Break #3 / #4 — POST no longer overwrites, 201 instead of 200

Section titled “Break #3 / #4 — POST no longer overwrites, 201 instead of 200”
# before — same name twice, second call overwrote
POST /webhook { "name": "main", "url": "https://a/" } -> 200 { url:"https://a/", secret:"…" }
POST /webhook { "name": "main", "url": "https://b/" } -> 200 { url:"https://b/", secret:"…" } (overwrite)
# after
POST /webhook { "name": "main", "url": "https://a/" } -> 201 { name:"main", url:"https://a/", secret:"…" }
POST /webhook { "name": "main", "url": "https://b/" } -> 409 { "code": "name conflict" }
PATCH /webhook/main { "url": "https://b/" } -> 200 { url:"https://b/", … }

Break #6 / #7 — name in path, slug regex

Section titled “Break #6 / #7 — name in path, slug regex”
# valid
POST /webhook/main-prod { "url": "https://hooks.example.com/" } -> 201
# invalid name
POST /webhook { "name": "Main_Prod!", "url": "https://hooks.example.com/" } -> 400 { "code":"invalid name" }

Break #8 — URL validation: https + public address

Section titled “Break #8 — URL validation: https + public address”
POST /webhook/dev { "url": "http://localhost:3000/hook" } -> 400 { "code":"invalid url" }
POST /webhook/dev { "url": "https://10.0.0.5/hook" } -> 400 { "code":"invalid url" }
POST /webhook/dev { "url": "https://hooks.example.com/" } -> 201
# clear card.updated, keep everything else
PATCH /webhook/main { "card": { "updated": null } }
-> 200 { "url": "...", "transaction": {...}, "user": {...} } # no "card" group anymore
# replace url only
PATCH /webhook/main { "url": "https://new.example.com/" }
-> 200 { "url": "https://new.example.com/", "transaction": {...}, ... }

Note: the secret is not returned by PATCH (only by POST on create).

Break #10 / #11 — DELETE is path-based and not idempotent

Section titled “Break #10 / #11 — DELETE is path-based and not idempotent”
# before
DELETE /webhook
Content-Type: application/json
{ "name": "main" }
-> 200 { "code": "ok" } # even when "main" didn't exist
# after
DELETE /webhook/main
-> 200 { "code": "ok" }
DELETE /webhook/main # again
-> 404 { "code": "not found" }
// before
{ "secret": "9f3c…", /* 32 hex chars (16 bytes) */ }
// after
{ "secret": "9f3c…ab12…", /* 64 hex chars (32 bytes) */ }
  1. Swap DELETE /webhook (body) → DELETE /webhook/:name (path).
  2. Accept 201 on POST /webhook[/:name]; handle 409 name conflict explicitly.
  3. Add a PATCH /webhook/:name code path for updates instead of re-POSTing.
  4. Handle 404 on GET /webhook/:name (single missing); note the collection GET /webhook still returns 200 {} when empty.
  5. Slugify names to [a-z0-9-]{1,64} before any call that takes :name.
  6. Widen secret storage to 64 chars.