Webhooks
React to events as they happen instead of polling. Register an endpoint, subscribe to the event types you care about, and receive signed events when mail is delivered, a participant acts, or a file seals.
Webhooks let you react to Keepable events the moment they happen (a delivery landing, a signer signing, a long-retained item finally reaching a newly-registered recipient) instead of polling for changes. You register an HTTPS endpoint, subscribe it to the event types you care about, and Keepable POSTs a signed event to it.
Managing endpoints needs the webhooks.write scope; listing them and reading
the delivery log needs webhooks.read.
Register an endpoint
POST your receiving URL and the event types to subscribe to. The signing secret is returned exactly once, in this response: store it immediately; you cannot retrieve it again.
POST https://api.keepable.co/sender/v2/webhook-endpoints
Authorization: Bearer {{KEEPABLE_TOKEN}}
Idempotency-Key: 5f3c1e02-9b7a-4a11-8c2e-6d0f4b8a1c93
Content-Type: application/json
{
"url": "https://example.com/hooks/keepable",
"events": ["participant.acted", "correspondence.send_completed", "correspondence.sealed"]
}The URL must be https, carry no credentials, and resolve to a public address;
anything else is refused at create rather than failing quietly at delivery time.
The 201 carries the signing secret. It is shown once and never again:
{
"endpoint_id": "whe_01J8ZQ4T",
"url": "https://example.com/hooks/keepable",
"events": ["participant.acted", "correspondence.send_completed", "correspondence.sealed"],
"active": true,
"created_at": "2026-05-24T10:00:00Z",
"secret": "whsec_abc123"
}events is the field name, and an endpoint created without it subscribes to
nothing. It answers 201 and then never delivers, which looks exactly like a
working integration with no traffic yet.
secret appears only in this create response; list responses omit it. If
you lose it, rotate the endpoint's secret rather
than deleting it. Use the secret to
verify every delivery's signature.
List, update, and delete endpoints as needed (list responses never include the secret):
# List
GET https://api.keepable.co/sender/v2/webhook-endpoints?limit=50
Authorization: Bearer {{KEEPABLE_TOKEN}}
###
# Update the URL or subscribed event types
PATCH https://api.keepable.co/sender/v2/webhook-endpoints/whe_01J8ZQ4T
Authorization: Bearer {{KEEPABLE_TOKEN}}
Content-Type: application/json
{ "events": ["participant.acted", "correspondence.sealed", "claim.claimed"] }
###
# Delete
DELETE https://api.keepable.co/sender/v2/webhook-endpoints/whe_01J8ZQ4T
Authorization: Bearer {{KEEPABLE_TOKEN}}Rotate the signing secret
Mint a fresh secret without changing the endpoint. The old secret stops working, so cut your verifier over to the new one promptly. This is the right move if a secret leaks or you lose the one from create:
POST https://api.keepable.co/sender/v2/webhook-endpoints/whe_01J8ZQ4T/rotate-secret
Authorization: Bearer {{KEEPABLE_TOKEN}}
Idempotency-Key: 5f3c1e02-9b7a-4a11-8c2e-6d0f4b8a1c93The response carries the new secret, again shown exactly once.
Send a test event
Fire a synthetic event at an endpoint to confirm it is reachable and your signature check works, before real traffic depends on it:
POST https://api.keepable.co/sender/v2/webhook-endpoints/whe_01J8ZQ4T/test
Authorization: Bearer {{KEEPABLE_TOKEN}}
Idempotency-Key: 5f3c1e02-9b7a-4a11-8c2e-6d0f4b8a1c93It delivers a single event of type webhook.test with a { "message": … }
data payload, signed like any real delivery, and shows up in the
delivery log. It is the one event type that is not in
the catalogue above: nothing in your organisation produced it, so nothing can
subscribe to it, and a test fire ignores the endpoint's events list.
Event catalogue
type | Fires when |
|---|---|
correspondence.created | A file is created, by a direct call or a plan run. |
correspondence.send_completed | The queued run behind a 202 has drained. Carries the delivered / held / undeliverable counts against the total. |
participant.acted | Delivery, read, approval, signature, consent decision, submission. data.act narrows it. |
document.attached | A document is added to a live file. |
form.response_received | One participant submits their answers. |
correspondence.sealed | A file completed and sealed into a covenant. |
correspondence.withdrawn | A live file was pulled back. |
claim.claimed | A claim code was redeemed. |
claim.expired | A claim code lapsed unredeemed. |
wallet.debited | A send debited the prepaid wallet. |
campaign.status_changed | A campaign authored in the portal changed state. |
correspondence.send_completed is the one that closes the loop on a send.
POST /correspondence answers 202 with every participant pending, so this
is how you learn the run finished and what it came to.
participant.acted is the one to wire first. It covers what used to be a
dozen kind-specific events, and data.act tells you which act it was. There is
no agreement.signed separate from consent.decided separate from
content.arrived, because a signature, a decision, and a delivery are the same
kind of fact about a participant.
Retention has no event of its own. When somebody you sent retained mail to
finally verifies, the held items deliver and each fires participant.acted
like any other delivery. Subscribe to it to observe a retained send completing.
The envelope
{
"event_id": "evt_01J8ZQ4T",
"type": "participant.acted",
"at": "2026-07-24T10:00:00Z",
"data": {
"correspondence_id": "cor_01J8ZQ4T",
"participant_id": "prt_01J8ZQ4T",
"act": "approved",
"ref": "CUST-100401"
}
}Branch on type. Keep event_id for
idempotent processing.
data carries the ids to re-fetch plus a compact snapshot. Treat the API as
the source of truth: an event tells you something happened and gives you
enough to act, but a payload that raced another one is not the record. Re-read
the correspondence when it matters.
Alongside the body, each delivery carries:
| Header | Carries |
|---|---|
X-Keepable-Signature | t=<unix>,v1=<hex>. Verify it. |
X-Keepable-Event-Id | The same value as event_id, to de-duplicate. |
X-Keepable-Event-Type | The type, so you can route without parsing the body. |
Respond fast
Return 200 as soon as you have durably accepted the event, ideally after
just enqueuing it, before any heavy work. Keepable treats a non-2xx or a slow
response as a failure and retries. Do the real
processing asynchronously.
Delivery and retries
Keepable retries failed deliveries with backoff, so your endpoint should expect at-least-once delivery: the same event may arrive more than once. De-duplicate on the event id.
Inspect recent delivery attempts to debug a flaky endpoint:
GET https://api.keepable.co/sender/v2/webhook-deliveries?limit=50&status=dead
Authorization: Bearer {{KEEPABLE_TOKEN}}{
"deliveries": [
{
"delivery_id": "b3f1c0d2-5e77-4a8e-9c31-2f6a4d8b1e05",
"endpoint_id": "whe_01J8ZQ4T",
"event_id": "evt_01HXP",
"event_type": "participant.acted",
"status": "delivered",
"attempts": 1,
"last_error": "",
"created_at": "2026-05-24T10:00:00Z",
"delivered_at": "2026-05-24T10:00:01Z"
}
],
"next_cursor": null
}Narrow it with endpoint_id and status; both are applied in the query rather
than to the page that came back, so a filtered page is a full page. Pass
next_cursor back as cursor to walk further, exactly as everywhere else.
Delivery status | Meaning |
|---|---|
pending | Not yet acknowledged; retries may still be in flight. |
delivered | Your endpoint returned 2xx. |
dead | Retries exhausted. The event was not accepted; investigate last_error. |
A dead delivery means you missed an event. Reconcile from the delivery list
and the read endpoint it points at: the event_id and event_type say what you
missed, and
re-reading the correspondence
tells you where it got to.
Next
Verify webhook signatures
Every delivery is signed. Confirm it is genuinely from Keepable, reject replays, and process exactly once.
Billing
Keepable is prepaid. Read your balance and ledger, and pull invoices as PDFs, so reconciliation happens in your systems rather than in a browser tab.
Verify signatures
Every webhook delivery is HMAC-SHA256 signed with your endpoint's secret. Verify the signature, enforce a timestamp window, and de-duplicate on the event id before you trust a payload.