Webhooks
Webhooks let an external system listen for Hydra events in real time. When a customer is created, a ticket gets resolved, or an account moves to "at risk," Hydra POSTs a signed JSON payload to the URL you've registered. This is the fastest way to wire Hydra into Zapier, n8n, or a custom integration without polling the REST API.
Where to manage them
Settings → Webhooks. From there you can create, pause, edit, send a test event, view recent failures, rotate the signing secret, and delete a subscription.
What gets sent
Every event POST has the same envelope:
{
"event": "ticket.created",
"tenant_id": "...",
"data": {
"entity_type": "ticket",
"entity_id": "...",
"entity": { /* the full entity row, same shape as the public REST API */ }
},
"timestamp": "2026-04-29T18:33:00Z"
}
The HTTP request also carries:
Content-Type: application/jsonX-Hydra-Event: <event>— for quick branching without parsing the bodyX-Hydra-Signature: t=<unix_ts>,v1=<hex>— see "Verifying signatures" belowUser-Agent: Hydra-Webhooks/1.0
The request times out after 8 seconds. Anything in the 200–299 range counts as success; everything else is a failure and triggers a retry.
Available event types
The event picker in Settings → Webhooks is the source of truth, but the current set is:
| Event | When it fires |
|---|---|
customer.created | A new lead or contact is created (any source — widget, CSV import, MCP, public API). |
account.created | A new account is created. |
account.stage_changed | An account's lifecycle_stage changes (prospect → active, etc.). |
account.went_at_risk | An account specifically transitions into at_risk — useful for ops alerts. |
conversation.created | A new conversation is opened (widget, email, etc.). |
conversation.resolved | A conversation is marked resolved. |
conversation.assigned | A conversation is assigned to an agent. |
ticket.created | A new ticket is opened. |
ticket.assigned | A ticket is assigned to an agent. |
ticket.resolved | A ticket transitions to resolved. |
ticket.status_changed | A ticket transitions to any non-resolved status. |
A subscription that subscribes to multiple events receives one POST per event — no batching.
Verifying signatures
Every request carries an X-Hydra-Signature header in the form t=<unix_ts>,v1=<hex>. To verify:
- Compute
HMAC_SHA256(signing_secret, "${t}.${rawBody}"). Use the EXACT raw bytes of the request body — don't re-serialize from a parsed JSON, that introduces whitespace differences. - Compare in constant time against the
v1value. - Reject if
|now - t| > 300 secondsto defeat replay attacks.
Node example:
const crypto = require('crypto')
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest()
const provided = Buffer.from(parts.v1, 'hex')
if (provided.length !== expected.length) return false
if (!crypto.timingSafeEqual(provided, expected)) return false
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
return true
}
The signature shape mirrors Stripe's Stripe-Signature so the same library patterns apply.
Signing secrets
When you create a webhook, Hydra generates a 32-byte random secret prefixed whsec_…. It's shown once at creation time and never again. If you lose it, click "Rotate secret" to mint a new one — your endpoint must be updated immediately, or signature verification will fail on the next event.
In the subscription list view, only the prefix of the secret is shown so you can confirm which one you have.
Retry behavior
When a delivery fails (network error, timeout, or any non-2xx response), Hydra schedules a retry. The default schedule is exponential, capped at 1 hour:
| Attempt | Delay since previous |
|---|---|
| 1 | (immediate) |
| 2 | ~30 seconds |
| 3 | ~2 minutes |
| 4 | ~10 minutes |
| 5 | ~30 minutes |
| 6 | ~1 hour |
After six total attempts, the delivery is marked failed and removed from the retry queue. The full failure history (status code, error message, response time per attempt) is visible in Settings → Webhooks → History.
Successful inline deliveries don't write history rows — only failures and their retries do — so the history view is intentionally focused on what needs your attention.
Testing
The "Send test" button on each subscription posts a synthetic webhook.test event to your endpoint, signed with your real secret. The response status code and body are surfaced inline so you can validate the wiring without waiting for a real event to fire.
Disabling temporarily
The "Pause" button flips active=false on the subscription. Pending retries stop firing, no new events get queued, and your endpoint goes silent. Resume to re-enable; queued events that fired during the pause are not replayed.
Permissions
Creating, editing, and deleting subscriptions is admin-only (Owner or Admin role). Members can see them in the Settings list but cannot modify them.
Troubleshooting
- All my deliveries are failing with 401/403 — your endpoint is rejecting the request. Check that you're verifying signatures correctly; many libraries default to URL-encoded form parsing, which strips the raw bytes the signature needs.
- Signature validation fails on every request — make sure you're hashing the RAW body, not a re-serialized version. Express's
json()middleware re-emits without preserved whitespace; you needexpress.raw()or equivalent. - Some events don't fire — confirm the event type is in your subscription's
event_typeslist. The Settings UI shows the full set of available events; if you don't see one you expect, it may not have an emitter wired yet. - Pending retries piling up — check the History panel; the most recent error message (from the latest attempt) tells you why your endpoint is rejecting. The retry queue auto-drains as the endpoint recovers.
