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.
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
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 meanshttp://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:
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.
