Skip to main content
Webhooks are how a deployed agent reacts without polling. Sente POSTs a JSON event to an HTTPS endpoint you own the moment something happens: an email lands in an identity’s inbox (message.received), or a run needs a human, finishes, or fails (run.blocked, run.completed, run.failed). Every delivery to your organization carries the same org-level secret in the X-Sente-Secret header. Verifying that header is the whole authentication story — there is no signature to compute and no raw-body handling, so a normal JSON body parser is fine.

Register an endpoint

POST /v1/webhooks with { url, events, identityId? }. Omit identityId for an org-wide subscription; set it to receive only that identity’s events. Registration is idempotent per (url, identity scope): re-registering the same pair updates its event list instead of creating a duplicate, so it is safe to call on every app start.
The secret is org-level: one value verifies every delivery to your organization, across all webhooks and all identities. It is returned when you register and never by GET /v1/webhooks. Store it like an API key — there is no self-serve rotation endpoint today.
URL rules. The URL must be http(s) and resolve to a public address. Loopback, private-range (RFC 1918), CGNAT, link-local, and cloud-metadata addresses are rejected at registration with 400 and re-checked immediately before every delivery, so a hostname that later resolves into private space stops being delivered to.

Handle a delivery

Compare the header against your stored secret with a constant-time compare, acknowledge fast, then do the work. Sente gives each delivery 10 seconds before it counts as failed, so never block the response on your own processing.

Events and delivery semantics

The run.* events are fire-and-forget. If your endpoint is down, slow past 10 seconds, or returns a non-2xx at that moment, that notification is gone — there is no retry. For anything you must not miss, keep GET /v1/runs/:id (or GET /v1/runs?status=blocked) as a reconciliation path. message.received is the one queued event: a job whose deliveries all fail is retried, and eventually parked in a dead-letter queue.
Scoping is per subscription: an org-wide webhook (no identityId) receives events for every identity; an identity-scoped one receives only that identity’s. If both match, both are delivered — deduplicate if you register overlapping subscriptions.

message.received

A thin notification with no body. Fetch the full message — text, HTML, and the annotation that carries an extracted OTP or magic link — with GET /v1/messages/:id (sente.messages.get(id)).
Inbound only. An identity’s own outbound sends never fire this event. And if you are waiting on a verification code inside a Sente-driven run, you do not need this at all — the run applies the code itself.

run.blocked

Fired the instant a register, login, or connect run parks for a human. It carries everything needed to route a person to the gate.
Not fired when you pause a run yourself with POST /v1/runs/:id/intervene — you already know.

run.completed and run.failed

run.failed is the same shape with "status": "failed", result: null, and the failure code/detail set — see the failure codes.

Test locally without a public URL

The SSRF guard means http://localhost:3000 can never be registered. Instead, have the CLI relay inbound mail to your local handler with the exact same envelope and X-Sente-Secret header:
This covers message.received only. For run.*, drive a run and poll GET /v1/runs/:id, or expose a tunnel with a public hostname and register that.

When deliveries don’t arrive

Next steps

Audit trail

The pull-based record of who touched credentials and sessions.

Human takeover

What to do with a run.blocked event once it reaches a person.

Verification codes

The annotation on an inbound message, and waiting for one directly.

Webhooks API reference

Endpoint fields, status codes, and exact payloads.