Webhooks
React to events as they happen instead of polling. Register an endpoint, subscribe to event types, and receive signed CloudEvents when content arrives, agreements progress, or consent is decided.
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 and reading deliveries needs the webhooks.write scope.
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.
curl https://api.keepable.co/sender/webhook_endpoints \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/keepable",
"event_types": ["content.arrived", "agreement.completed"]
}'{
"id": "whe_01HXP",
"url": "https://example.com/hooks/keepable",
"event_types": ["content.arrived", "agreement.completed"],
"active": true,
"created_at": "2026-05-24T10:00:00Z",
"secret": "whsec_abc123"
}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
curl "https://api.keepable.co/sender/webhook_endpoints?limit=50" \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24"
# Update the URL or subscribed event types
curl -X PATCH https://api.keepable.co/sender/webhook_endpoints/whe_01HXP \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Content-Type: application/json" \
-d '{ "event_types": ["content.arrived", "agreement.completed", "consent.decided"] }'
# Delete
curl -X DELETE https://api.keepable.co/sender/webhook_endpoints/whe_01HXP \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24"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:
curl -X POST https://api.keepable.co/sender/webhook_endpoints/whe_01HXP/rotate_secret \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)"The 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:
curl -X POST https://api.keepable.co/sender/webhook_endpoints/whe_01HXP/test \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24" \
-H "Idempotency-Key: $(uuidgen)"It 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.
Event catalogue
Subscribe using the short event name in event_types. Each delivered event
carries the full CloudEvents type and a typed data payload:
| Subscribe as | CloudEvents type | Fires when | data carries |
|---|---|---|---|
content.arrived | co.keepable.content.arrived | A delivered item lands in an inbox | tenant_id, content_id, recipient_id, content_type, arrived_at |
agreement.created | co.keepable.agreement.created | An agreement is created | tenant_id, agreement_id, status, occurred_at |
agreement.sent | co.keepable.agreement.sent | A draft agreement is sent for signing | tenant_id, agreement_id, status, occurred_at |
agreement.signed | co.keepable.agreement.signed | A participant signs | …plus signature_id |
agreement.completed | co.keepable.agreement.completed | All signers have signed | tenant_id, agreement_id, status, occurred_at |
agreement.revoked | co.keepable.agreement.revoked | An agreement is revoked | tenant_id, agreement_id, status, occurred_at |
agreement.expired | co.keepable.agreement.expired | An agreement expires unsigned | tenant_id, agreement_id, status, occurred_at |
consent.requested | co.keepable.consent.requested | A consent request is delivered to the recipient | tenant_id, request_id, status, occurred_at |
consent.decided | co.keepable.consent.decided | The recipient grants or declines | …plus action (grant/decline) |
consent.replaced | co.keepable.consent.replaced | A replacement acknowledgement is delivered | tenant_id, request_id, status, occurred_at |
consent.expired | co.keepable.consent.expired | A consent request expires undecided | tenant_id, request_id, status, occurred_at |
consent.withdrawn | co.keepable.consent.withdrawn | The sender withdraws the request | tenant_id, request_id, status, occurred_at |
Retention has no
event of its own: when someone you sent retained mail to finally signs up, the
held items are delivered, and each delivery fires content.arrived just like any
other. Subscribe to content.arrived to observe a retained send completing.
The CloudEvents envelope
Events are delivered as a CloudEvents 1.0 JSON envelope
over a POST with Content-Type: application/json. The envelope wraps your
typed data:
{
"specversion": "1.0",
"id": "evt_01HXP",
"source": "https://api.keepable.co/sender/tenants/ten_01HXP",
"type": "co.keepable.content.arrived",
"time": "2026-05-24T10:00:00Z",
"datacontenttype": "application/json",
"subject": "cnt_01HXP",
"data": {
"tenant_id": "ten_01HXP",
"content_id": "cnt_01HXP",
"recipient_id": "rcp_01HXP",
"content_type": "letter",
"arrived_at": "2026-05-24T10:00:00Z"
}
}Branch your handler on the envelope type. The id is the unique event id;
keep it for idempotent processing.
Alongside the body, each delivery carries three headers:
| Header | Carries |
|---|---|
X-Keepable-Signature | The HMAC signature, t=<unix>,v1=<hex>. Verify it. |
X-Keepable-Event-Id | The event id, the same value as the envelope id. Use it to de-duplicate. |
X-Keepable-Event-Type | The event 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 (the last 30 days) to debug a flaky endpoint:
curl "https://api.keepable.co/sender/webhook_deliveries?limit=50" \
-H "Authorization: Bearer $KEEPABLE_TOKEN" \
-H "Keepable-Version: 2026-05-24"{
"deliveries": [
{
"id": "whd_01HXP",
"endpoint_id": "whe_01HXP",
"event_id": "evt_01HXP",
"event_type": "content.arrived",
"status": "delivered",
"attempts": 1,
"last_error": "",
"created_at": "2026-05-24T10:00:00Z",
"delivered_at": "2026-05-24T10:00:01Z"
}
],
"next_token": null
}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. Use the delivery list plus the
relevant read endpoint (e.g. fetch the agreement, or
list inbox content) to reconcile.
Next
Verify webhook signatures
Every delivery is signed. Confirm it is genuinely from Keepable, reject replays, and process exactly once.
Invoices
List your Keepable invoices for the last 12 months and download any of them as a PDF, straight from the API.
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.