FerrisKeyDocs

Payload format

When a webhook fires, FerrisKey sends an HTTP POST to the endpoint with a JSON body. The envelope is the same for every trigger. What changes is data, and a few headers let you check who sent it.

What the request carries

HeaderValue
content-typeapplication/json
x-ferriskey-timestampUnix time in seconds at which this attempt was signed
x-ferriskey-signatureLowercase hex HMAC-SHA256 of the timestamp and the body, described below
x-ferriskey-delivery-idThe id of the delivery. It stays the same across retries and replays
Your custom headersThe ones you configured on the webhook

FerrisKey waits up to 5 seconds to connect and 10 seconds for the whole request. Any 2xx answer counts as delivered. A redirect does not: FerrisKey does not follow redirects, so a 301 from http to https is recorded as a failed delivery with the error code http_301.

Envelope

{
  "event": "user.created",
  "timestamp": "2026-03-17T14:30:00.123456+00:00",
  "resource_id": "01914b3c-1234-7f5a-b456-000000000001",
  "data": {
    "id": "01914b3c-5678-7f5a-b456-000000000002",
    "realm_id": "01914b3c-1234-7f5a-b456-000000000001",
    "username": "alice",
    "firstname": "Alice",
    "lastname": "Smith",
    "email": "alice@example.com",
    "email_verified": false,
    "enabled": true,
    "required_actions": [],
    "created_at": "2026-03-17T14:30:00.101234Z",
    "updated_at": "2026-03-17T14:30:00.101234Z",
    "failed_login_attempts": 0,
    "locked_until": null,
    "locale": null,
    "federation_provider_id": null
  }
}
FieldTypeDescription
eventstringThe trigger name, for example user.created
timestampstringRFC 3339 time at which the event was built
resource_idUUIDThe realm id for every trigger except auth.reset_password, where it is the user id
dataany JSONThe object the event is about, or its id. See the next section

resource_id does not identify the object that changed

For nearly every trigger, resource_id is the id of the realm the event happened in. It tells you which realm, not which user, client or role. Read the object’s own id from data.id, or from data itself when the event carries only an id. The same value fills the resource_id of the delivery record.

What data holds

Triggersdata
user.created, user.updated, user.deleted, user.email_verifiedThe user object, including id, username, email, enabled and email_verified. For user.deleted it is the user as it was before deletion
user.bulk_deletedAn array with the ids of the deleted users
user.role.assignedThe role object (id, name, permissions and the rest). It does not say which user received the role
auth.reset_passwordnull. resource_id is the user id
auth.device_flow.initiated, .denied, .expiredsession_id, realm_id, client_id, status, user_id and device_code. The code is in clear for initiated, and a SHA-256 hex digest for the other two
client.created, client.updated, client.maintenance.enabled, client.maintenance.disabledThe client object. The client secret appears as ***
client.deleted, redirect_uri.deleted, web_origin.deleted, client.saml_attribute_mapper.deletedThe id of the removed object, as a string
redirect_uri.created, redirect_uri.updated, web_origin.created, client.saml_config.updated, client.saml_attribute_mapper.createdThe object itself
role.created, role.updated, role.deleted, role.permission.updatedThe role object. permissions is an array of permission names
realm.updated, realm.deletedThe realm object
realm.settings.updatedThe realm settings object
webhook.created, webhook.updated, webhook.deletedThe webhook, without its headers and secret

Timestamps inside data end in Z, while the envelope’s timestamp ends in +00:00. Both are RFC 3339, so any parser accepts them.

Examples

{
  "event": "role.permission.updated",
  "timestamp": "2026-03-17T15:00:00.482913+00:00",
  "resource_id": "01914b3c-1234-7f5a-b456-000000000001",
  "data": {
    "id": "01914b3c-9abc-7f5a-b456-000000000010",
    "name": "editor",
    "description": null,
    "permissions": ["view_users", "manage_users"],
    "realm_id": "01914b3c-1234-7f5a-b456-000000000001",
    "client_id": null,
    "client": null,
    "require_mfa": false,
    "created_at": "2026-03-01T09:00:00.000000Z",
    "updated_at": "2026-03-17T15:00:00.470112Z"
  }
}
{
  "event": "client.deleted",
  "timestamp": "2026-03-17T15:30:00.912004+00:00",
  "resource_id": "01914b3c-1234-7f5a-b456-000000000001",
  "data": "01914b3c-def0-7f5a-b456-000000000020"
}
{
  "event": "auth.reset_password",
  "timestamp": "2026-03-17T16:10:00.250117+00:00",
  "resource_id": "01914b3c-5678-7f5a-b456-000000000002",
  "data": null
}

Custom headers

Webhooks can carry custom HTTP headers, sent on every delivery. Use them for a bearer token or a routing tag your endpoint expects:

{
  "endpoint": "https://api.yourapp.com/webhooks/ferriskey",
  "headers": {
    "Authorization": "Bearer your-secret-token",
    "X-Webhook-Source": "ferriskey"
  }
}

FerrisKey refuses to save a webhook whose headers include host, content-type, content-length, transfer-encoding, x-ferriskey-signature, x-ferriskey-timestamp or x-ferriskey-delivery-id, because it sets those itself. The values are never returned by the API. Leave headers out of a PUT to keep the stored set; send it to replace the whole set.

A static header proves that the caller knows a token. It does not prove that the body was not altered, and it can be replayed. The signature covers both.

Verifying the signature

Every delivery is signed with the webhook’s secret. The signature is the lowercase hex HMAC-SHA256 of this string:

{x-ferriskey-timestamp}.{raw request body}

That is the decimal value of the timestamp header, a literal dot, and the exact bytes of the body. The key is the secret as you received it, taken as text: the 64 hexadecimal characters themselves, not their decoded bytes. Binding the timestamp into the signed string is what stops someone who captured a request from sending it again later with a fresh timestamp header.

Get the secret

The secret is shown once, when you call POST /realms/{realm_name}/webhooks/{webhook_id}/secret/rotate or use Generate a new secret in the console. Neither creating nor reading a webhook returns it. Store it where your endpoint can read it.

Keep the raw body

Compute the HMAC over the bytes you received, before any JSON parsing. Parsing and serializing again changes whitespace and key order, and the signature no longer matches.

Check the timestamp

Reject the request when x-ferriskey-timestamp is far from your clock. Five minutes is a reasonable tolerance. FerrisKey signs every attempt with the time of that attempt, so retries and manual replays pass this check even when the original event is old. Check the header, not the timestamp field of the body.

Compare in constant time

Compute the expected signature and compare it with x-ferriskey-signature using a constant-time comparison.

import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 300

export function verifyFerrisKeySignature(secret, headers, rawBody) {
  const timestamp = headers['x-ferriskey-timestamp']
  const received = headers['x-ferriskey-signature']
  if (!timestamp || !received) return false

  const age = Math.abs(Date.now() / 1000 - Number(timestamp))
  if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex')

  const a = Buffer.from(expected, 'hex')
  const b = Buffer.from(received, 'hex')
  return a.length === b.length && timingSafeEqual(a, b)
}

Wired into a plain Node server, the function receives the body as a Buffer, before anything parses it:

import { createServer } from 'node:http'
import { verifyFerrisKeySignature } from './verify.mjs'

const secret = process.env.FERRISKEY_WEBHOOK_SECRET

createServer((req, res) => {
  const chunks = []
  req.on('data', (chunk) => chunks.push(chunk))
  req.on('end', () => {
    const rawBody = Buffer.concat(chunks)

    if (!verifyFerrisKeySignature(secret, req.headers, rawBody)) {
      res.writeHead(401).end()
      return
    }

    const payload = JSON.parse(rawBody.toString('utf8'))
    console.log(req.headers['x-ferriskey-delivery-id'], payload.event)
    res.writeHead(204).end()
  })
}).listen(8080)

FerrisKey signs each attempt with the secret in force at that moment. Once you rotate, any attempt that reaches an endpoint still holding the old secret fails verification. If you answer it with a 4xx other than 408 or 429, FerrisKey does not retry it and you replay it by hand, so have the new secret ready for your endpoint before you generate it.

Handling a delivery well

Respond quickly

Return a 2xx as soon as you have stored the payload, and do the heavy work in the background. FerrisKey stops waiting after 10 seconds, records transport_error, and tries again later.

Be idempotent

The same delivery can reach you more than once: automatic retries after a timeout, or a manual replay. Use x-ferriskey-delivery-id as the deduplication key. It is the same on every attempt of one delivery. resource_id and timestamp cannot do that job, since resource_id is usually the realm.

Verify before you parse

Check the signature first. Do not act on a payload from a caller that cannot produce one.

Read data by event

data can be an object, a string, an array or null, depending on the trigger. Branch on event before you read fields, as the table above lays out.

Log the delivery id

Log x-ferriskey-delivery-id with each payload. When a handler fails silently, that id is how you find the matching row under Deliveries and retries.