Keepable docs
Webhooks

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"]
  }'
201 Created: secret shown once
{
  "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 asCloudEvents typeFires whendata carries
content.arrivedco.keepable.content.arrivedA delivered item lands in an inboxtenant_id, content_id, recipient_id, content_type, arrived_at
agreement.createdco.keepable.agreement.createdAn agreement is createdtenant_id, agreement_id, status, occurred_at
agreement.sentco.keepable.agreement.sentA draft agreement is sent for signingtenant_id, agreement_id, status, occurred_at
agreement.signedco.keepable.agreement.signedA participant signs…plus signature_id
agreement.completedco.keepable.agreement.completedAll signers have signedtenant_id, agreement_id, status, occurred_at
agreement.revokedco.keepable.agreement.revokedAn agreement is revokedtenant_id, agreement_id, status, occurred_at
agreement.expiredco.keepable.agreement.expiredAn agreement expires unsignedtenant_id, agreement_id, status, occurred_at
consent.requestedco.keepable.consent.requestedA consent request is delivered to the recipienttenant_id, request_id, status, occurred_at
consent.decidedco.keepable.consent.decidedThe recipient grants or declines…plus action (grant/decline)
consent.replacedco.keepable.consent.replacedA replacement acknowledgement is deliveredtenant_id, request_id, status, occurred_at
consent.expiredco.keepable.consent.expiredA consent request expires undecidedtenant_id, request_id, status, occurred_at
consent.withdrawnco.keepable.consent.withdrawnThe sender withdraws the requesttenant_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:

POST to your endpoint
{
  "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:

HeaderCarries
X-Keepable-SignatureThe HMAC signature, t=<unix>,v1=<hex>. Verify it.
X-Keepable-Event-IdThe event id, the same value as the envelope id. Use it to de-duplicate.
X-Keepable-Event-TypeThe 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"
200 OK
{
  "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 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. 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.