Payment Webhooks

Register webhook endpoints to receive real-time notifications when payments are completed, failed, refunded, or when subscriptions change.

Base URL: https://payments.audit1.com/api/v1 — every endpoint on this page lives there, a different host from the core Developer API (https://apiv2.audit1.com/api/v2).

Webhook subscriptions are self-service — nothing to ask Audit1 to configure. Register your own endpoint below, and manage it (edit, rotate its secret, send yourself a test event, check delivery history) with the endpoints under Manage Webhooks.


Register a Webhook #

POST /api/v1/webhooks
Field Type Required Description
url string Yes HTTPS endpoint to receive events. Non-https URLs are rejected
events array No Event types to subscribe to (omit for all current event types)
curl -X POST https://payments.audit1.com/api/v1/webhooks \
  -H "X-Client-ID: $CLIENT_ID" \
  -H "X-Client-Secret: $CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourplatform.com/webhooks/audit1",
    "events": ["payment.completed", "payment.failed", "payment.refunded"]
  }'
{
  "ok": true,
  "data": {
    "id": "685abc123def456789012345",
    "url": "https://yourplatform.com/webhooks/audit1",
    "secret": "whsec_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6",
    "events": ["payment.completed", "payment.failed", "payment.refunded"],
    "status": "active",
    "created_at": "2026-05-01T12:00:00.000Z"
  }
}

Save the secret immediately — it's only shown once. You'll use it to verify webhook signatures.

One URL per client.

Registering a url you've already registered returns 409 Conflict with the existing subscription's id in the response, instead of creating a duplicate — use Update a Webhook to change its events instead of registering it again.


Event Types #

Event Trigger
payment.completed A payment taken through Stripe checkout completed — a card, or a Stripe bank debit (us_bank_account) where that method is offered, on Payment Links and embedded checkout. It never fires for Audit1's own bank rail. A bank payment on a payment session reports through the payment.debit_* events below instead; a debit you raise with POST /api/v1/ach/debits reports through none of them today — poll its status
payment.failed Payment attempt failed
payment.refunded Refund processed
subscription.created New recurring subscription started
subscription.cancelled Subscription cancelled
installment.paid Installment payment received
installment.failed Installment payment failed
payment.debit_minted A scheduled ACH debit was created. It carries a cancellation_deadline — your chance to cancel before the next batch closes and the debit becomes irreversible. It covers provider-initiated debits only — a bank payment made by a customer on a payment session does not mint one
payment.debit_cancelled A scheduled ACH debit was cancelled before it moved money
payment.debit_cleared The bank debit was matched in the bank account that received it — the money is actually there. Fires on the bank match, and only for carriers whose collected money lands in an Audit1 collection account (settlement_type: "fbo"). It arrives about a week after the processor reports settlement — see How long a bank clearance takes. The payload carries confidence (high / medium / low); gate automation on high
payment.debit_settled The ACH processor reports the debit settled. This is the processor's word, not a bank-verified fact. Today it is sent only for direct-settlement carriers (settlement_type: "direct"), whose collected money lands in the carrier's own bank and never passes through an account we can read — so no bank confirmation can ever exist there, and the processor's report is the strongest signal that rail can produce

Bank debits: debit_cleared vs debit_settled #

Availability — read this before you build on them.

Both events are new (August 2026). Which rails are live for the carriers behind your policies can change — support@audit1.com will confirm before you rely on them. Three things decide whether one reaches you:

  • They fire only for payments arranged through a payment session. A debit you created directly against your API key (POST /api/v1/ach/debits) has no session behind it and emits neither — nor do a plan's individual instalments; only its down payment does.
  • Which one you get follows the carrier's settlement rail: payment.debit_cleared needs an FBO carrier, because a bank match cannot exist anywhere else, and payment.debit_settled is sent today only for direct-settlement carriers. Some carriers produce neither.
  • An existing subscription does not pick up a new event type on its own — you have to send the full events list again.

These are two different claims about the same money, and every payload says which one you are holding, in settlement_source:

payment.debit_cleared payment.debit_settled
settlement_source "bank" "processor"
What it means The dollar was matched against the bank account that received it The processor's system says the debit completed
How strong it is Confirmed A report we are passing on
Which carriers FBO-settled only — a bank match can exist only where the money lands in an Audit1 collection account we can read Direct-settled today — the money lands in the carrier's own bank, which we cannot read, so the processor's word is all there can be
Timing Slower. The bank has to show it, then we have to match it Faster

Per carrier you get one of the two today, not both. The events come from two different services, and each picks its own carriers. payment.debit_cleared comes from the bank matcher, which only ever looks at money that landed in an Audit1 collection account — so it is FBO-only by construction, not by policy. payment.debit_settled comes from the ACH settlement sync, and that emitter narrows to settlement_type: "direct" legs before it sends anything. So a policy on an FBO carrier produces the bank-matched event, a policy on the direct carrier produces the processor's report, and one debit does not produce both.

Write a handler that tolerates both anyway — the split lives in the emitters, not in the contract.

The receiving endpoints accept either event for any carrier; only the two senders restrict themselves. If emission is ever widened, both events can arrive for the same debit. Being ready costs nothing: deduplicate on X-Webhook-ID, key on leg_id to recognise that two events describe one debit, and treat payment.debit_cleared as the bank's verdict — it supersedes the processor's claim whenever both are in hand. What you should not build is an either/or branch that throws the second event away.

If you bind coverage or release a policy on collected money, bind on payment.debit_cleared wherever it is available — it is the only one of the two that means the money is in the account. payment.debit_settled exists for the rail where a bank confirmation is impossible, and for anyone who wants the earlier signal and accepts it for what it is.

Carriers that produce neither event #

The two rails are not a complete map of our carriers. A carrier is FBO-settled or direct-settled only if somebody has recorded which; today 8 of the 69 live carriers have neither — 5 are marked reporting-only, and 3 carry no settlement rail at all (counted 2026-08-18).

Neither sender reaches those. The bank matcher never sees their money, and the settlement sync's emitter only sends for a carrier recorded as direct — so a bank session on one of them emits no debit event at all today, and its status stays scheduled indefinitely rather than moving to paid.

The other 61 do not split evenly: 60 settle FBO and 1 settles direct. So payment.debit_cleared is the event nearly every policy produces, and payment.debit_settled covers a single carrier today. Handle both names — settlement_type is a field on the carrier record and it can change — but expect the bank-matched event to be the one you actually see.

So before you offer the bank rail on a carrier, check with us which rail it settles on — or poll the session instead of waiting on a webhook that is not coming.

How long a bank clearance takes #

About a week. Measured over the 275 debits matched since the 2026-08-04 backfill that carry a processor settlement date (314 matched in all), as of 2026-08-18, the median gap between the ACH processor reporting the debit settled and payment.debit_cleared reaching you is roughly 7 days — 6.76 days at the median, 7.26 at the 90th percentile. The population is deliberately the live matches rather than the backfill that seeded the matcher, so the figure describes the wait you will actually see.

It is two waits, not one: the money posts to the bank about 4–5 days after the processor's report, and the matcher confirms it against that account about 2 days later. We are working to shorten the second half.

payment.debit_settled carries the processor's report itself, so on the direct rail you hear about the money at the start of that window rather than the end.

An existing subscription does not pick up a new event type on its own.

Your events list is a snapshot taken when you registered. To start receiving these two, PATCH your subscription with the full list you want — everything you already receive, plus the new names. The list is replaced, not merged.


Webhook Payload #

Each delivery includes these headers:

Header Description
X-Webhook-Signature HMAC-SHA256 signature
X-Webhook-Timestamp Unix timestamp in milliseconds
X-Webhook-Event Event type (e.g., payment.completed)
X-Webhook-ID The event's id — the deduplication key. It identifies the event, not the attempt, so it is the same on the first POST and on every retry or background redelivery of that event. See Retry Policy

The delivered request body is the event's data object directly — there is no { "ok": true, "data": ... } envelope on webhook deliveries (that envelope is only used by the REST API responses documented on the other pages).

Payment Completed Fields #

Fields for the payment.completed event's data object:

Field Type Description
payment_id string Internal id of the completed payment record
payment_link_id string The payment link this payment was made through
stripe_payment_link_id string Stripe's payment link id (a sandbox placeholder id in test mode)
policy_id string Policy the payment applies to. Omitted for a carrier-billed link with no policy
employer_id string Employer the payment applies to. Omitted for a carrier-billed link
carrier_id string Carrier on the policy
amount_cents integer Amount actually collected in this charge. For a DIRECT_BILLING link this is the down payment charged now, not the full link total
amount_paid_cents integer Alias of amount_cents — both are always present; use whichever reads better in your codebase
currency string ISO-4217 currency code. Always "usd" today
premium_cents integer The link's premium amount (describes the link, not this specific charge)
fee_cents integer The link's fee amount (describes the link, not this specific charge)
fee_source string "partner" when the link's fee_cents was set by you at creation. Omitted entirely (not null) when the link has no fee — check for the key's presence, don't assume it's always there
metadata object The metadata you passed when creating the payment link, plus two keys we add: api_client_id (your tenant id) and source ("payment_api"). Your own keys come back with their values untouched, with one exception — api_client_id and source are reserved. Set either yourself and ours silently overwrites it, so don't use those two names for your own data. The two injected keys are a payment-link guarantee only — a link created through POST /api/v1/payment-links with no metadata still delivers both. A payment completed through a Custom Checkout session may deliver metadata as {} or omit it entirely, so never build a handler that requires the injected keys to be present
payment_type string ONE_TIME or DIRECT_BILLING
payment_context string The payment_context from the original payment link
paid_at string ISO 8601 timestamp of completion

Example:

{
  "payment_id": "686abc123def456789012345",
  "payment_link_id": "683abc123def456789012345",
  "stripe_payment_link_id": "plink_1abc2def3ghi...",
  "policy_id": "682def789ghi012345678901",
  "employer_id": "681xyz789abc123456789012",
  "carrier_id": "680abc456def789012345678",
  "amount_cents": 150000,
  "amount_paid_cents": 150000,
  "currency": "usd",
  "premium_cents": 145000,
  "fee_cents": 5000,
  "fee_source": "partner",
  "metadata": {
    "invoice_number": "INV-2044",
    "api_client_id": "684f1a2b3c4d5e6f70819234",
    "source": "payment_api"
  },
  "payment_type": "ONE_TIME",
  "payment_context": "down_payment",
  "paid_at": "2026-04-14T15:30:00.000Z"
}

This same shape is delivered whether the payment completed for real or was triggered with Simulate a Completed Payment in sandbox — it's the identical dispatch path either way, not a separate mocked format.

Info

The test event also delivers metadata: {}. That sample body is hand-built and predates the two injected keys, so it has never carried them. Between session payments and test fires, an empty or absent metadata is a normal thing for a handler to receive — don't assert on api_client_id or source anywhere.

Bank Debit Fields #

payment.debit_cleared and payment.debit_settled open with the same identity block, then each carries its own evidence. Fields shared by both:

Field Type Description
payment_session_id string The payment session this debit was arranged under. Always present — a debit with no session emits nothing at all
leg_id string Internal id of the debit itself. The same value across every event about this one debit
amount_cents integer What the session asked for, in cents
currency string ISO-4217 currency code. Always "usd" — the bank rail is US-only
employer_id string Employer on the session, as an ObjectId string
policy_id string Policy on the session, as an ObjectId string
carrier_id string Carrier on the session
description string The description you set when you created the session. Omitted entirely (not null) if you set none
settlement_source string "bank" on payment.debit_cleared, "processor" on payment.debit_settled. It is on every payload so you never have to infer which claim you're holding from the event name alone

Info

employer_id and policy_id are the resolved ObjectIds, not necessarily the strings you sent. If you created the session with a slug, this is what that slug resolved to — match on it accordingly. Both are present on every bank debit; the bank rail refuses to start a session without them.

An absent optional field is left out, never sent as null. Check for the key's presence rather than for a null value.

Then, on payment.debit_cleared — the bank's confirmation:

Field Type Description
bank_date string The date the bank shows the money landed, YYYY-MM-DD. This is the money's date, not the delivery time of this event, which is later
matched_cents integer The amount matched in the bank, in cents. Always equal to amount_cents — the matcher pairs on the exact amount and nothing else, so a short or split bank credit produces no match and no event at all. It is never a smaller number here
confidence string high, medium, or low — how unambiguous the pairing was. Present on every match
matcher_version string Which version of the matcher produced it, e.g. "bank-match/v1". Diagnostic only. Omitted entirely (not null) when the match carries no version stamp
{
  "payment_session_id": "68b7c1d4e9b3d70012ab9911",
  "leg_id": "68c3a91f4b2e5d0012cd7788",
  "amount_cents": 1173800,
  "currency": "usd",
  "employer_id": "68a1f4c2e9b3d70012ab34cd",
  "policy_id": "68a1f4c2e9b3d70012ab34ce",
  "carrier_id": "680abc456def789012345678",
  "description": "Workers' Comp premium — August 2026",
  "settlement_source": "bank",
  "bank_date": "2026-08-14",
  "matched_cents": 1173800,
  "confidence": "high",
  "matcher_version": "bank-match/v1"
}

A medium or low match is still delivered. We tell you the confidence rather than holding the event back, so you can set your own bar instead of waiting on money that has already landed. If a webhook starts an automated action — binding coverage, releasing a policy, closing a receivable — gate it on confidence: "high" and route the rest to a human.

And on payment.debit_settled — the processor's report:

Field Type Description
settled_at string The day the processor reports the debit settled, written as a full ISO 8601 timestamp — e.g. "2026-08-09T12:00:00.000Z". Only the calendar day carries meaning, and you must read that day in UTC. Before sending, we take the UTC calendar date of the value we hold and re-anchor it at noon UTC, so the clock is padding that keeps the field a well-formed timestamp — it is not a moment anyone observed. Never read a time-of-day, a timezone, or an ordering within the day out of it, and do not convert it to a far-eastern local zone: that will roll it to the wrong day. Where the day itself comes from is below
processor_status string The processor's own status word for the settled debit, forwarded verbatim — we do not restyle its casing or spacing. It is a closed set. The value passes through an allow-list on its way out, so what reaches you is one of the six spellings below, or the field is omitted entirely (never null) when the processor gave us no word. Nothing else can appear on this event. It is still a label to log, never to branch on — the event itself is the signal that the processor called this debit settled

The six spellings are two states, which the processor writes more than one way:

State Spellings you may receive
Remit SB Remit, SBRemit, ServiceBureauOutboundFileSent, SendProcessComplete
Transmitted FileTransmitted, File Transmitted

A spaced and an unspaced spelling are the same state because both are separately enumerated in the allow-list — the matching itself forgives only case and surrounding whitespace, never internal spacing (a rule loose enough to merge SB Remit and SBRemit would be loose enough to let an unknown word through). The value you receive keeps the spelling the processor used, which is why one state can reach you under two labels on two events. If you map this field at all, map it to the two states, never to six branches. And treat "omitted" as ordinary: plenty of legs carry no processor word, and their settlement is carried by the event itself.

Info

ServiceBureauProcessingIntoRemitFile is not on that list, and its absence is deliberate. The processor uses it to mean the item is being processed into the remit file — in flight, not settled — so the allow-list refuses it and it can never accompany a payment.debit_settled. If you meet that word elsewhere in our data, it is a different field on a different record, not this one.

{
  "payment_session_id": "68b7c1d4e9b3d70012ab9911",
  "leg_id": "68c3a91f4b2e5d0012cd7788",
  "amount_cents": 1173800,
  "currency": "usd",
  "employer_id": "68a1f4c2e9b3d70012ab34cd",
  "policy_id": "68a1f4c2e9b3d70012ab34ce",
  "carrier_id": "680abc456def789012345678",
  "description": "Workers' Comp premium — August 2026",
  "settlement_source": "processor",
  "settled_at": "2026-08-09T12:00:00.000Z",
  "processor_status": "SB Remit"
}

Where the settled date comes from

Three sources, in order, and the payload does not tell you which one you got — so treat the field as day precision with a day of slack, never as a timestamp, and never as the input to a to-the-day reconciliation:

  1. The processor's transmission date — the day it says it sent the debit. This is the normal case.
  2. The effective date, when the processor surfaced no transmission date. That is the day the debit was scheduled for, so settled_at can read as the same day the debit was raised.
  3. The day we read it, when the processor surfaced no date at all. This one is our own observation rather than anything the processor stated. It is rare, and it is disclosed here precisely because the payload cannot flag it — it can fall a day or more after the money actually moved.

Whichever of the three a given debit came from, it is normalised the same way before it reaches you: the value's UTC calendar date, re-anchored at noon UTC. That is why the clock always reads 12:00:00.000Z, and why it means nothing.

The practical reading: the day is approximately right, the clock is not meaningful, and a debit's real money movement can sit a day either side of it. If you need the day the money actually arrived in a bank account, that is bank_date on payment.debit_cleared, not this field.

Neither event is ever taken back. A repeat of the same observation is not re-sent; a genuine re-match against a different bank transaction is a new event. If a debit is returned after it cleared, that is a separate thing we will name separately — it is not this event firing backwards.

Whichever of the two applies to your carrier is also what closes the underlying session out as paid, moving it on from scheduled — see Track a session. A session selling an instalment plan is the exception: it stays scheduled for the life of the plan by design, so its down payment clearing sends you the event without moving the status.

Other event types (payment.failed, payment.refunded, subscription.created, etc.) share the same header contract; their data fields are not yet catalogued field-by-field on this page — reach out to support@audit1.com if you need the exact shape of one.


Verifying Signatures #

Always verify the X-Webhook-Signature to confirm the event came from Audit1 and wasn't tampered with.

Verification Steps #

  1. Extract X-Webhook-Signature and X-Webhook-Timestamp from headers
  2. Construct the signed payload: ${timestamp}.${raw_body}
  3. Compute HMAC-SHA256 using your webhook secret
  4. Compare signatures (use constant-time comparison)

JavaScript #

const crypto = require("crypto");

function verifyWebhook(secret, signature, timestamp, body) {
  const payload = `${timestamp}.${body}`;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(payload)
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signature)
  );
}

// In your webhook handler:
app.post("/webhooks/audit1", (req, res) => {
  const sig = req.headers["x-webhook-signature"];
  const ts = req.headers["x-webhook-timestamp"];

  if (!verifyWebhook(WEBHOOK_SECRET, sig, ts, req.rawBody)) {
    return res.status(401).send("Invalid signature");
  }

  // Process the event — the event name arrives in the header, the body is the data object
  const eventType = req.headers["x-webhook-event"];
  const data = req.body;
  console.log(`Received ${eventType} for payment ${data.payment_id}`);
  res.status(200).send("OK");
});

Python #

import hmac, hashlib

def verify_webhook(secret, signature, timestamp, body):
    payload = f"{timestamp}.{body}"
    expected = hmac.new(
        secret.encode(), payload.encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)

Retry Policy #

Delivery happens in two stages. The first rides out a blip in the seconds after the event. The second is what makes delivery survive a real outage — an endpoint that is down for minutes or hours catches up on its own, and you have nothing to replay.

Stage 1 — the immediate ladder #

If your endpoint doesn't respond with 2xx within 10 seconds, we retry with exponential backoff. The first attempt fires immediately; subsequent attempts wait the delay below.

Attempt Delay before this attempt
1 — (immediate)
2 1 second
3 5 seconds
4 25 seconds
5 ~2 minutes (125 s)

Five attempts, about 2.6 minutes end to end, all carrying the same X-Webhook-ID. When the fifth one fails the event is marked failed — check delivery history for the last status code or error.

Stage 2 — background redelivery, 12 attempts across 48 hours #

failed is not where it ends. An event the ladder could not place is picked up by a scheduled background job and re-POSTed — the same event, the same X-Webhook-ID12 times, on an escalating schedule measured from when the event was created, not from the last attempt:

Attempt Fires at (event age) Attempt Fires at (event age)
1 5 minutes 7 12 hours
2 15 minutes 8 16 hours
3 45 minutes 9 24 hours
4 2 hours 10 32 hours
5 4 hours 11 40 hours
6 8 hours 12 44 hours

Tight at the front and wide at the back, on purpose: an endpoint that reboots in ten minutes catches up in ten minutes, and one that is down for a day is still tried without the whole budget being burned in the first hour. Anchoring on the event's age rather than on "now + delay" is what keeps a late or missed run from sliding the ladder past the window.

The ladder runs while the event is under 48 hours old and your subscription is still active. Come back up before the next rung is due and the stranded events arrive on their own.

A redelivery is a probe of your endpoint, not a fresh observation of the money. It never produces a second event.

The twelfth attempt is the last one. It fires around hour 44, roughly four hours inside the window — so an event whose budget is spent will not be tried again even though it is still under 48 hours old. "Inside the window" is not the same as "still coming": if your outage ran past the last rung, the event is waiting on a conversation, not on a timer. Ask us at support@audit1.com.

failed is a stage, not a verdict #

failed in delivery history means "the immediate ladder gave up", not "we stopped trying". It is exactly the state stage 2 works on, and it flips to delivered the moment your endpoint accepts the event. Don't alert on failed as a permanent loss. Before asking us to re-send, look at how old the event is: young enough that rungs remain, and it is already on its way back; past the hour-44 rung, and it is not — that one we should hear about.

After 48 hours #

An event still not accepted when it ages out of the window is dropped. It is not retried again and it will not turn up later. It is not lost quietly, though: it is surfaced to Audit1 ops, so a partner who has been silently missing money events gets noticed rather than left. If you know you were down that long, contact support@audit1.com and we can re-post the observation from our own record.

The same event, never a second one #

Delivery is at least once. X-Webhook-ID identifies the event, not the attempt: it is identical on the first POST, on every ladder retry, and on every background redelivery. So a second POST bearing an id you have already processed is a retry, never a second observation, and dropping it is always safe. Recording the ids you have handled and ignoring repeats is the whole integration requirement.

Do not dedupe on X-Webhook-Signature or X-Webhook-Timestamp. The timestamp is stamped fresh at every attempt and the signature covers it, so both differ between two deliveries of the same event.

What is not a repeat: a re-derive of the same bank match is silent, and a genuine re-match against a different bank transaction is a new observation with a new id.

Auto-disable #

After 10 consecutive fully-failed events — each having exhausted its own 5-attempt ladder on the live delivery path — the subscription is automatically disabled (status: "disabled"). A single successful delivery resets the counter, so an endpoint that's merely flaky doesn't get disabled by unrelated later successes.

Background redeliveries do not count toward that. Only the live path moves the counter; a stranded event being probed in the background can never be what disables you.

A disabled subscription receives nothing — including background redeliveries, which stop while it is disabled. Re-enable it with PATCH /api/v1/webhooks/{id} and { "status": "active" }, which also resets the failure counter; any stranded event still inside the window with attempts left resumes from the rung it had reached.


Manage Webhooks #

Every endpoint below is tenant-scoped — you can only see and modify your own subscriptions.

List Webhooks #

GET /api/v1/webhooks

Returns every subscription you've registered, newest first. secret is never included — only Rotate Signing Secret or the original registration response ever show it.

Warning

This route keys the record as _id, not id. Register and Rotate return a mapped id; List, Update and Delivery History return the stored record, whose identifier is _id. It is the same value in both cases — the inconsistency is in the key name, and it is real. Read record._id ?? record.id if you touch more than one of these routes.

{
  "ok": true,
  "data": [
    {
      "_id": "685abc123def456789012345",
      "url": "https://yourplatform.com/webhooks/audit1",
      "events": ["payment.completed", "payment.failed", "payment.refunded"],
      "status": "active",
      "failure_count": 0,
      "created_at": "2026-05-01T12:00:00.000Z",
      "updated_at": "2026-05-01T12:00:00.000Z"
    }
  ]
}

Update a Webhook #

PATCH /api/v1/webhooks/{id}

Edit any combination of url, events, and status — send only the fields you want to change.

Field Type Description
url string New endpoint. Must be https. Changing it to a URL you already use on another subscription returns 409 Conflict
events array Replaces the full subscription list. Must be a non-empty subset of the event types above
status string active or disabled. Setting active also resets failure_count to 0 — the standard way to re-enable a subscription the auto-disable tripped
curl -X PATCH https://payments.audit1.com/api/v1/webhooks/685abc123def456789012345 \
  -H "X-Client-ID: $CLIENT_ID" \
  -H "X-Client-Secret: $CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'

Returns the updated subscription (same shape as List, secret omitted). 404 if the id isn't yours.

Rotate Signing Secret #

POST /api/v1/webhooks/{id}/rotate

Issues a new signing secret and immediately invalidates the old one. Like the secret shown at registration, it's returned once — save it before it scrolls off your screen.

{
  "ok": true,
  "data": {
    "id": "685abc123def456789012345",
    "secret": "whsec_9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c",
    "updated_at": "2026-08-09T15:04:00.000Z"
  }
}

Send a Test Event #

POST /api/v1/webhooks/{id}/test

Delivers one sample event to your endpoint right now, using the real signature and header contract, so you can confirm your handler works without waiting for (or simulating) an actual payment.

Field Type Required Description
event_type string No Which of the subscription's events to send a sample of. Defaults to the first event in the subscription's list

A test delivery makes exactly one attempt (no retries) and never affects the subscription's health — it can't reset a healthy failure count, and it can't be the delivery that trips the auto-disable threshold. It is also never redelivered in the background: a test event that your endpoint refused is simply reported to you here, not re-POSTed hours later.

{
  "ok": true,
  "data": {
    "delivered": true,
    "event_id": "68abc1234567890123456789",
    "event_type": "payment.completed",
    "url": "https://yourplatform.com/webhooks/audit1",
    "attempts": 1,
    "status_code": 200
  }
}

delivered: false with a status_code or error means the operation ran but your endpoint didn't return 2xx — the test itself still succeeded in telling you that.

View Delivery History #

GET /api/v1/webhooks/{id}/deliveries?limit=50

Recent delivery attempts for one subscription, newest first. limit defaults to 50, maximum 200.

{
  "ok": true,
  "data": [
    {
      "_id": "68abc1234567890123456789",
      "subscription_id": "685abc123def456789012345",
      "event_type": "payment.completed",
      "status": "delivered",
      "attempts": 1,
      "last_response_code": 200,
      "last_attempt_at": "2026-08-09T15:00:00.000Z",
      "delivered_at": "2026-08-09T15:00:00.000Z",
      "created_at": "2026-08-09T15:00:00.000Z",
      "payload": { "payment_id": "686abc123def456789012345" }
    }
  ]
}

Warning

Keyed _id, and it returns the stored record whole. Like List Webhooks, this route hands back the delivery record as we store it, so the identifier is _id rather than id. The record also carries fields this example trims for readability — payload (the full body we sent), last_error, and the background-redelivery bookkeeping (dedupe_key, sweep_attempts, next_sweep_after, sweep_claimed_at, last_swept_at). Read the fields you need by name; do not assume the shape is limited to what is shown here, and treat the sweep fields as internal — they exist to explain a delivery's history, not as a contract.

status is pending, delivered, retrying, or failed. A record from Send a Test Event carries "test": true.

failed here means the immediate ladder gave up — the event is still being redelivered in the background, up to 12 attempts with the last around hour 44, and can still become delivered. See Retry Policy.

Delete a Webhook #

DELETE /api/v1/webhooks/{id}

Removes the subscription immediately — no further events are delivered to it. 404 if the id isn't yours.


Best Practices #

✓ Do ✗ Don't
Respond with 200 quickly, process async Block the response while doing heavy processing
Verify the HMAC signature on every event Trust events without signature verification
Handle duplicate events idempotently Assume each event is delivered exactly once
Use HTTPS endpoints only Use HTTP (we reject non-HTTPS URLs)
Log the X-Webhook-ID for debugging Ignore event IDs