Webhook
A Webhook is a subscription to one or more SystemEvent slugs. When a matching event is emitted, the platform POSTs a signed payload to your URL.
Purpose
- Push state changes (new emails, agent results, rejections) into your systems in real time.
- Replace polling. Combine with
GET /system-eventsfor backfill. - Forward to ERPs, ticket systems, internal queues, Slack relays, etc.
Schema
| Field | Type | Description |
|---|---|---|
id | UUID | Internal id. |
workspace_id | UUID | Owning workspace. |
inbox_id | UUID | null | If set, only events for this inbox are delivered. |
status | enum | active, inactive. Auto-flipped to inactive after N consecutive failures. |
label | string | Human label. |
url | string | Target URL. https:// required outside development. |
signing_secret | string | whsec_…. Returned only at creation / rotation. Signs every delivery — see Webhook security. |
signing_secret_rotated_at | datetime | null | Last rotation; null if never rotated. |
signing_secret_previous_expires_at | datetime | null | Until when the previous secret still signs deliveries after a rotation. |
auth | enum | none, basic, bearer, header. |
auth_basic_user | string | null | When auth=basic. |
auth_basic_password | encrypted | When auth=basic. Never returned. |
auth_bearer_token | encrypted | When auth=bearer. Never returned. |
auth_header_key | string | null | When auth=header — custom header name. |
auth_header_value | encrypted | When auth=header. Never returned. |
subscribed_events | string[] | Array of SystemEvent.slug values. |
consecutive_failures | int | Internal counter — flips status to inactive past a threshold. |
created_at / updated_at / deleted_at | datetime |
GET responses substitute the secret/credential fields with auth_configured: true. To rotate the signing secret use POST /webhooks/{id}/regenerate-secret (the previous secret keeps signing for a grace window, 24 h by default); to change auth credentials use PATCH with a new credential payload.
Subscribed events
subscribed_events accepts any SystemEvent.slug.
Row-level events — emitted automatically on every create/update of the underlying row (bulk updateMany/upsert do not emit; inside a transaction they are filed after commit):
workspace.db.created,workspace.db.updatedinbox.db.created,inbox.db.updatedagent.db.created,agent.db.updated,agent.version_updated— prompt version bumped;data.version: { from, to }.agent_run.db.created,agent_run.db.updated,agent_run.status_changed—data.status: { from, to }.webhook.db.created,webhook.db.updatedemail.db.created,email.db.updated,email.status_changed—data.status: { from, to }.workflow_run.db.created,workflow_run.db.updated,workflow_run.status_changed—data.status: { from, to }.
Every *.db.updated carries data.changed_fields (changed columns, snake_case). A soft delete shows up as *.db.updated with changed_fields: ["deleted_at"].
Domain events — emitted explicitly:
email.rejected— email rejected;data.reason.email.agent.processed— agent finished;data.cost_limit_breached, plusdata.workflow_run_idwhen the agent returned a workflow.workflow.run.succeeded/workflow.run.failed— an agent workflow finished; the failure carriesdata.failed_step(the first failed step),data.failed_steps(every failed step) anddata.error(the first failure's error).workspace.cost_limit_breached— workspace went pastmonthly_cost_limit.webhook.delivery.failed— a delivery exhausted its retries.webhook.ping— synthetic event from/webhooks/{id}/test.
New slugs may be added without notice. Unsubscribed slugs are silently ignored.
Delivery contract
Outbound POST headers:
Content-Type: application/json
User-Agent: Kobana-Inbox-Webhook/1.0
X-Kobana-App: inbox
X-Kobana-Environment: production
X-Kobana-Webhook-Id: <Webhook.id>
X-Kobana-Event: email.db.created
X-Kobana-Event-Id: <SystemEvent.id>
X-Idempotency-Key: <SystemEvent.id>
X-Kobana-Delivery-Id: <WebhookDelivery.id>
X-Kobana-Timestamp: 1747490400
X-Kobana-Signature: v1,BASE64(HMAC_SHA256(key, "<delivery_id>.<timestamp>.<raw_body>"))
webhook-id: <WebhookDelivery.id>
webhook-timestamp: 1747490400
webhook-signature: v1,BASE64(HMAC_SHA256(key, "<delivery_id>.<timestamp>.<raw_body>"))
Plus whichever Authorization (or custom) header is configured. X-Kobana-App and X-Kobana-Environment (development, sandbox, production) identify the sender when one endpoint receives webhooks from several Kobana products or environments. The webhook-* headers repeat the delivery id, timestamp and signature in the Standard Webhooks layout, so any off-the-shelf verifier works. X-Idempotency-Key carries the event id on the original dispatch and every automatic retry, so an endpoint that honours idempotency keys processes it once. A manual replay carries a fresh, random key instead — see Replay.
Body:
{
"event": { "slug": "email.agent.processed", "id": "uuid" },
"delivered_at": "2026-05-17T12:00:00Z",
"workspace": { "id": "uuid", "external_id": "string | null", "slug": "acme" },
"data": {
"organization": { "public_id": "uuid", "external_id": "string | null", "slug": "acme-org" },
"agent": { "id": "uuid", "external_id": "string | null", "version": 3 },
"inbox": { "id": "uuid", "external_id": "string | null", "slug": "support" },
"email": {
"id": "uuid",
"message_id": "<...@mail>",
"status": "processed",
"rejection_reason": null,
"metadata": null,
"agent_metadata": {},
"custom_metadata": null,
"aws_ses_metadata": {},
"download_url": "https://app.inbox.kobana.com.br/eml/uuid/eyJhbGciOi..."
},
"agent_run": {
"id": "uuid",
"status": "success",
"llm_model": "anthropic/claude-sonnet-5",
"agent_version": 3,
"input_tokens": 1200,
"output_tokens": 300,
"cache_read": 0,
"cache_write": 0,
"total_cost": 0.0123,
"duration_ms": 4200
},
"cost_limit_breached": false
}
}
data holds resource snapshots taken at delivery time (organization, agent, inbox, email, agent_run) plus the keys specific to the event, all in snake_case. Email events carry all five sections, null when the event is not tied to that resource; other events carry the subset listed below. agent.version is the prompt version that actually ran when an agent_run exists, otherwise the agent's current version. Soft-deleted rows keep their id and have every other field set to null.
email.download_url is a ready-to-use link to the original .eml, valid for 30 minutes from delivery — see Downloading the .eml from a webhook for how it differs from GET /v1/emails/{id}/eml.
Sections and event-specific keys:
| Event | Sections in data | Extra keys |
|---|---|---|
email.db.created | workspace, organization, agent, inbox, email, agent_run | — |
email.db.updated | same | changed_fields |
email.status_changed | same | status: { from, to } |
email.rejected | same | reason |
email.agent.processed | same | cost_limit_breached, workflow_run_id |
workflow.run.succeeded | workspace, organization, agent, inbox, email, agent_run (resolved through the agent run that produced the workflow) | workflow_run_id, summary (total, succeeded, error), duration_ms |
workflow.run.failed | same | same, plus failed_step, failed_steps and error |
agent_run.db.created / agent_run.db.updated | workspace, organization, agent, inbox, email, agent_run | changed_fields (updated) |
agent_run.status_changed | workspace, organization, agent, inbox, email, agent_run | status: { from, to } |
workspace.cost_limit_breached | workspace, organization, agent, inbox, email, agent_run (inbox resolved through the run) | — |
workspace.db.created / workspace.db.updated | workspace, organization | changed_fields (updated) |
inbox.db.created / inbox.db.updated | workspace, organization, inbox, agent | changed_fields (updated) |
agent.db.created / agent.db.updated | workspace, organization, agent | changed_fields (updated) |
agent.version_updated | workspace, organization, agent | version: { from, to } |
webhook.db.created / webhook.db.updated | workspace, organization, inbox, webhook: { id, label } | changed_fields (updated) |
webhook.delivery.failed | workspace, organization, agent, inbox, email, agent_run, webhook, event: { id, slug } | — |
webhook.ping | workspace, organization, webhook: { id, label } | — |
Verifying the signature
Recompute HMAC_SHA256(key, "{delivery_id}.{timestamp}.{raw_request_body}") where key is the base64-decoded part of the secret after whsec_, base64-encode it and compare in constant time with the v1,… token(s) in X-Kobana-Signature (or webhook-signature). During a rotation the header carries one token per valid secret. Reject anything where |now - timestamp| > 300s and deduplicate by X-Kobana-Delivery-Id. Step-by-step guide and code samples: Webhook security.
Retry policy
- Success: HTTP
2xxwithin 10 s. - Failure: timeout / non-2xx.
- Backoff:
1m, 5m, 30m, 2h, 6h, 24h— 6 attempts over ~33 h. - After exhaustion:
WebhookDelivery.status=failedandconsecutive_failures++. - Past threshold (configurable, default 10):
Webhook.statusflips toinactive; the workspace owner is notified.
Example
{
"id": "f6a7b8c9-0d1e-4f2a-3b4c-5d6e7f8a9b0c",
"workspace_id": "a8b2c1d4-9e7f-4a3b-8c5d-1f2e3a4b5c6d",
"inbox_id": "b3c4d5e6-7a89-4b0c-9d1e-2f3a4b5c6d7e",
"status": "active",
"label": "Triage → ERP",
"url": "https://erp.acme.com/inbox/hook",
"auth": "bearer",
"auth_configured": true,
"subscribed_events": ["email.db.created", "email.agent.processed"],
"consecutive_failures": 0,
"created_at": "2026-01-04T12:00:00.000Z",
"updated_at": "2026-05-17T08:12:00.000Z",
"deleted_at": null
}