Keepable
Webhooks

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-6d0f4b8a1c93

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:

POST https://api.keepable.co/sender/v2/webhook-endpoints/whe_01J8ZQ4T/test
Authorization: Bearer {{KEEPABLE_TOKEN}}
Idempotency-Key: 5f3c1e02-9b7a-4a11-8c2e-6d0f4b8a1c93

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. 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

typeFires when
correspondence.createdA file is created, by a direct call or a plan run.
correspondence.send_completedThe queued run behind a 202 has drained. Carries the delivered / held / undeliverable counts against the total.
participant.actedDelivery, read, approval, signature, consent decision, submission. data.act narrows it.
document.attachedA document is added to a live file.
form.response_receivedOne participant submits their answers.
correspondence.sealedA file completed and sealed into a covenant.
correspondence.withdrawnA live file was pulled back.
claim.claimedA claim code was redeemed.
claim.expiredA claim code lapsed unredeemed.
wallet.debitedA send debited the prepaid wallet.
campaign.status_changedA 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:

HeaderCarries
X-Keepable-Signaturet=<unix>,v1=<hex>. Verify it.
X-Keepable-Event-IdThe same value as event_id, to de-duplicate.
X-Keepable-Event-TypeThe 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 statusMeaning
pendingNot yet acknowledged; retries may still be in flight.
deliveredYour endpoint returned 2xx.
deadRetries 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.

On this page