Skip to content
Heedeo

12. Webhooks

Heedeo sends a signed request to your server when a flag or a visitor changes after the event: an operator confirms or dismisses a flag in Review, a person is trusted, blocked, merged or deleted. Your app can then act on its own, such as suspending an account whose flag was confirmed. The decision at signup stays in the event’s answer (section 5): a webhook is never in the signup path, and your signup never waits for one.

Add an endpoint

  1. Open Settings, Webhooks (https://app.heedeo.com/review/settings/webhooks) and choose Add endpoint. Only an owner of the workspace can add, edit, pause, roll or delete an endpoint; members can read the list and the delivery log.
  2. Enter the Endpoint URL: a public https address on your server, such as https://example.com/webhooks/heedeo. An http address, an address on your own network, one with a user name and password in it, or one over 2,048 characters is refused.
  3. Tick the Events to send. The first six are ticked for you. event.scored is not: it sends one request for every event your server sends.
  4. Choose Add endpoint. The page shows the endpoint’s signing secret, whsec_ and 32 characters. Put it in your server’s environment as HEEDEO_WEBHOOK_SECRET. An owner can show it again with Reveal.
  5. Choose Send test (Test and debug, below).

A workspace can have up to 10 endpoints, each with its own secret and its own events. A change goes to every enabled endpoint that listens for its type, with the same message id.

Verify each request

Each message is a POST with content-type: application/json, user-agent: Heedeo-Webhooks/1 and three headers:

HeaderWhat it holds
webhook-idthe message id, the same as the body’s id
webhook-timestampwhen this attempt was sent, in Unix seconds
webhook-signaturev1, and the signature in base64. It can hold several, separated by spaces

Verify the signature before you act on a message, and verify it over the raw body: the bytes exactly as they arrived. A framework that parses the JSON first loses them, and the signature no longer matches. This is the most common mistake.

With the package (verifyWebhook(), in @heedeo/sdk 0.1.0-pilot.17 or later), in a Next.js route handler:

// app/api/webhooks/heedeo/route.ts
import { verifyWebhook } from "@heedeo/sdk";

export async function POST(request: Request) {
  const { data: message, error } = verifyWebhook({
    body: await request.text(), // the raw body: never request.json()
    headers: request.headers,
    secret: process.env.HEEDEO_WEBHOOK_SECRET,
  });
  if (error) {
    console.warn("Heedeo webhook refused", error.code);
    return new Response(null, { status: 400 });
  }
  // A test from Settings carries sample ids: answer it and change nothing.
  if (message.test) return new Response(null, { status: 204 });
  // The same message can arrive twice: skip an id you have handled (your own table of ids).
  if (await alreadyHandled(message.id)) return new Response(null, { status: 204 });

  if (message.type === "flag.decided" && message.data.flag.visitor_id) {
    const { id, status, visitor_id, decided_at } = message.data.flag;
    // Order is not promised: a retried decision can arrive after a newer one, so keep each flag's
    // newest decided_at (your own table) and act only on a newer one.
    const newest = await newestDecision(id);
    if (!newest || new Date(decided_at) > newest) {
      if (status === "confirmed") await suspendVisitor(visitor_id); // your own code
      if (status === "open") await restoreVisitor(visitor_id); // the decision was undone
      await saveNewestDecision(id, new Date(decided_at));
    }
  }
  // the other types you listen for: What to do with each, below
  await markHandled(message.id);
  return new Response(null, { status: 204 }); // within 10 seconds; queue anything slow
}

message is typed by its type, so message.data has that type’s fields. Other frameworks:

  • Express: put the route before app.use(express.json()), with express.raw({ type: "application/json" }) on it, and pass body: req.body (a Buffer) and headers: req.headers.
  • Hono: body: await c.req.text(), headers: c.req.raw.headers.
  • Any other: read the body as text or bytes before any JSON parser runs, and pass the headers as the framework gives them.

verifyWebhook takes the body as a string or bytes (a parsed object throws, so the mistake shows on your first test), the headers as a Headers object or a plain object in any casing, and the secret. It answers { data, error } like the package’s other calls. error.code says which check failed, and never holds the secret or a signature:

error.codeWhat failed
invalid_keythe secret is missing or malformed; nothing was checked
webhook_headers_missingone of the three headers is missing
webhook_timestamp_invalidwebhook-timestamp is more than 5 minutes old or ahead of your clock, or not a Unix time
webhook_signature_invalidno v1 signature matches: the wrong secret (a rolled one), or a body that changed on its way
webhook_body_invalidthe signature matched, and the body is not a Heedeo message

toleranceSeconds changes the 5 minutes (300), and now passes a clock, for tests.

Without the package, in any language:

  1. Read webhook-id, webhook-timestamp and webhook-signature. Refuse a request without all three.
  2. Refuse a webhook-timestamp more than 5 minutes before or after your server’s clock. The time is signed, so a captured request cannot be sent again later.
  3. The signed content is {webhook-id}.{webhook-timestamp}.{body}: the two header values and the raw body, joined by full stops.
  4. The key is the secret’s part after whsec_, decoded from base64 (24 bytes), not the text of the secret.
  5. Compute the HMAC-SHA256 of the signed content under the key, and encode it in base64.
  6. Compare it with each signature in webhook-signature that starts v1, (the part after the comma), in constant time: hmac.compare_digest in Python, crypto.timingSafeEqual in Node, hmac.Equal in Go, ActiveSupport::SecurityUtils.secure_compare in Ruby, hash_equals in PHP. Skip any other version. One match is enough.
  7. Only then parse the body as JSON.

This is the Standard Webhooks format (https://www.standardwebhooks.com). The open standardwebhooks libraries for Python, Go, Ruby, PHP, Java, Rust, C# and Elixir verify Heedeo’s requests as they are, with the secret as Settings shows it. In Python, with Flask:

# pip install standardwebhooks
import os
from flask import Flask, request
from standardwebhooks.webhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["HEEDEO_WEBHOOK_SECRET"])

@app.post("/webhooks/heedeo")
def heedeo_webhook():
    try:
        message = webhook.verify(request.get_data(), request.headers)  # the raw body
    except WebhookVerificationError:
        return "", 400
    if message.get("test"):
        return "", 204  # a test from Settings: change nothing
    # skip a message["id"] you have handled, then act on message["type"]
    return "", 204

The message

Every message has the same envelope:

FieldWhat it holds
idthe message id, the same as webhook-id, on every retry and every endpoint. Dedupe on it
typeone of the seven types below
timestampwhen the change happened (ISO 8601, UTC), not when this attempt was sent
api_versionthe date of the message’s shape, 2026-11-08. It changes only when a field is removed, renamed, changes type or changes meaning
datathe change, with the fields of its type
testtrue on a message from Send test, whose ids are samples; absent on every other message

A message holds ids and labels as the API answers them, never a raw email or address or a person’s name (section 7). Times are ISO 8601. Each type follows, with when it is sent, its data fields and its sample, which is also what Send test sends.

A message can gain fields at any time: read the ones you know and ignore the rest. api_version changes only when a field is removed, renamed, changes type or changes meaning.

flag.created

Sent when Heedeo flags an event (flag: true), and when Heedeo opens a related flag on another visitor linked to the flagged one. Review lists both. A flag is raised for a person you pressed Trust on too, so that Review still shows them, and Auto-confirm leaves it for you. Act on the flag only when flag.listed is not allow.

FieldWhat it holds
flag.idthe flag
flag.kindevent, the event’s own flag, or related, a flag on a visitor related to the event’s
flag.event_idthe event that opened it
flag.riskwhat the flag is about: account_abuse, one person making more than one account; null when no risk judges the event’s action
flag.actionthe event’s action, such as signup, which the flag keeps after the event itself is deleted
flag.visitor_idthe person the flag is on; null for an event without a visitor
flag.created_atwhen it was opened
flag.listedyour label on what the flag is about, read when it was raised, as an answer’s listed reads it: allow, block or null. For an event flag, the person’s, the event’s email’s and its network’s, a Block winning; for a related flag, that visitor’s own. allow means you trust them: log it. Absent on a message sent before it
eventthe event’s evaluation, as POST /v1/evaluations answers it (section 4), in the latest API version, flagged and no_token first, each reason with its code, weight and base and without its text. Only on an event flag: a related flag has none, since that answer is another person’s. null when Heedeo flags an earlier action after its event was deleted, such as a signup found later to be a second account of one person

event.flagged is what the event was answered when it was scored, not whether to act on the flag. They differ for a flag Heedeo raises later on an earlier signup, when it learns that two people are one: its event is that signup’s answer, flag: false and flagged: false, on a person you may not trust at all. So read flag.listed. A related flag carries no event, and one whose event was already deleted has null; flag.listed is on both. A related flag is Review’s to decide, and acts on your app only through flag.decided.

The sample shows the answer’s first fields; the rest are section 4’s.

{
  "id": "3b8e2f0c-6d1a-4e7b-9f2c-5a4d3c2b1e0f",
  "type": "flag.created",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "flag": {
      "id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
      "kind": "event",
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "risk": "account_abuse",
      "action": "signup",
      "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "created_at": "2026-11-06T09:30:00.418207+00:00",
      "listed": null
    },
    "event": {
      "flagged": true,
      "no_token": false,
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "flag": true,
      "score": 100,
      "threshold": 100,
      "suggested": "review",
      "reasons": [{ "code": "many_identities", "weight": 100, "base": 100 }]
    }
  }
}

The same flag for a person you pressed Trust on. It is still raised and listed in Review, flag.listed is allow, and the event’s flag is true and its flagged false:

{
  "id": "5c4d3e2f-1a0b-4c9d-8e7f-6a5b4c3d2e1f",
  "type": "flag.created",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "flag": {
      "id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
      "kind": "event",
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "risk": "account_abuse",
      "action": "signup",
      "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "created_at": "2026-11-06T09:30:00.418207+00:00",
      "listed": "allow"
    },
    "event": {
      "flagged": false,
      "no_token": false,
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "flag": true,
      "score": 100,
      "listed": "allow",
      "reasons": [{ "code": "many_identities", "weight": 100, "base": 100 }]
    }
  }
}

flag.decided

Sent when a flag is confirmed or dismissed, in Review, by an AI assistant (section 11) or by Auto-confirm, and when a decision is undone. Auto-confirm never confirms a flag on a person, email or network you trust, unless a Block outweighs the Trust (section 6). One message per flag: deciding all of a person’s flags at once sends one for each.

FieldWhat it holds
flag.id, flag.kind, flag.event_id, flag.risk, flag.actionas in flag.created
flag.visitor_idthe flag’s person, by their id now
flag.statusconfirmed (abuse), dismissed (legit), open when a decision was undone, or withdrawn when Heedeo takes back a flag it raised later, before anyone decided it, because the two people it joined turned out to be two
flag.decision_idthe decision, which Undo reverses whole: every flag decided in the same step (a person’s flags at once in Review, or by an AI assistant) carries the same one. On open, the decision undone. An older message may hold null
flag.decided_atwhen it was decided or undone
{
  "id": "8c7d6e5f-4a3b-4c2d-9e1f-0a9b8c7d6e5f",
  "type": "flag.decided",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "flag": {
      "id": "1f2e3d4c-5b6a-4978-8695-a4b3c2d1e0f9",
      "kind": "event",
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "risk": "account_abuse",
      "action": "signup",
      "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "status": "confirmed",
      "decision_id": "6a5b4c3d-2e1f-4a0b-9c8d-7e6f5a4b3c2d",
      "decided_at": "2026-11-06T09:30:00.418207+00:00"
    }
  }
}

visitor.listed

Sent when a visitor, an email or a network is labelled allow or block, or its label is removed: Trust or Block in Review, an assistant, or POST /v1/lists (section 5, Let a real person through).

FieldWhat it holds
label.kindvisitor, email or network
label.valueas POST /v1/lists answers it: the visitor id, the email’s hash, or the network (its /24, or /48 for IPv6)
label.actionblock or allow, or null when the label was removed
visitor_idthe visitor, for a visitor label; null for an email or a network label
{
  "id": "1a2b3c4d-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
  "type": "visitor.listed",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "label": {
      "kind": "visitor",
      "value": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
      "action": "block"
    },
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f"
  }
}

visitor.merged

Sent when two visitors become one person (section 7, Merges): one id stays, and the others retire into it.

FieldWhat it holds
visitor_idthe id that stays
mergedthe ids that retired into it in this merge. Each still resolves to visitor_id (section 10)
{
  "id": "9f8e7d6c-5b4a-4f3e-8d2c-1b0a9f8e7d6c",
  "type": "visitor.merged",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
    "merged": ["5d1e7a9c-3b4f-4e2a-8c6d-7f8e9a0b1c2d"]
  }
}

visitor.split

Sent when a merge is undone: Not them on a person’s browser in Review, or an assistant’s split_browser (section 7, Merges).

FieldWhat it holds
visitor_idthe visitor that was split; its id stands
split_intothe other visitor, now beside it
browser_idthe browser taken out
browser_visitor_idwhich of the two holds that browser now: visitor_id or split_into
{
  "id": "2c3d4e5f-6a7b-4c8d-9e0f-1a2b3c4d5e6f",
  "type": "visitor.split",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
    "split_into": "3c2b1a0f-9e8d-4c7b-a6f5-e4d3c2b1a0f9",
    "browser_id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "browser_visitor_id": "3c2b1a0f-9e8d-4c7b-a6f5-e4d3c2b1a0f9"
  }
}

visitor.deleted

Sent when a person is deleted: Delete this person in Review, DELETE /v1/visitors/<visitor_id> or heedeo.deleteVisitor (section 7, Deleting a person). It is sent only when something was deleted, so a deletion sent again sends nothing.

FieldWhat it holds
visitor_idthe person deleted
aliasesevery id that had retired into them, the oldest merge first
{
  "id": "7a6b5c4d-3e2f-4a1b-8c0d-9e8f7a6b5c4d",
  "type": "visitor.deleted",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "visitor_id": "0b6f3c1e-8f2a-4d5b-9c7e-1a2b3c4d5e6f",
    "aliases": ["5d1e7a9c-3b4f-4e2a-8c6d-7f8e9a0b1c2d"]
  }
}

event.scored

Sent for every event, with its answer: one request for each event your server records, from evaluate(), logEvent() or a call of your own, whichever name it posts to. Off unless you tick it. It is for keeping your own copy of every answer, including one your server stopped waiting for.

FieldWhat it holds
eventthe event’s answer, as in flag.created
{
  "id": "4e5f6a7b-8c9d-4e0f-9a1b-2c3d4e5f6a7b",
  "type": "event.scored",
  "timestamp": "2026-11-06T09:30:00.412Z",
  "api_version": "2026-11-08",
  "data": {
    "event": {
      "flagged": true,
      "no_token": false,
      "event_id": "7e6d5c4b-3a2f-4e1d-9c0b-a9f8e7d6c5b4",
      "flag": true,
      "score": 100,
      "threshold": 100,
      "suggested": "review",
      "reasons": [{ "code": "many_identities", "weight": 100, "base": 100 }]
    }
  }
}

What to do with each

Your app holds the account; the message names the person by visitor_id, the visitor.id you stored from the event’s answer. A rule for each:

  • flag.created: when flag.kind is related, log it and wait for flag.decided. Otherwise, when flag.listed is allow, you trust the person: log it. Otherwise, under watch, hold what the account was given, or queue it for your own look; under score or rules your rule already ran at the event, so log it, unless event is null or event.flag is false: then Heedeo raised the flag later, after your rule let the signup through, so treat it as under watch. flag.listed is the label when the flag was raised, and a message can arrive late or be sent again. So before you hold an account, check the person’s own label now: GET /v1/visitors/<visitor_id> answers it as listed (a visitor label only, not one on an email or a network). When you cannot tell (no visitor_id, or your Trust was on the email or the network), queue the account for your own look rather than hold it.
  • flag.decided: on confirmed, suspend the accounts of flag.visitor_id or end their trial; on dismissed, lift any hold; on open, undo what you did on confirmed.
  • visitor.listed: for a visitor label, block suspends the person’s accounts, allow restores them, and null returns them to your usual rule; an email or network label names no visitor, and the next event’s listed carries it.
  • visitor.merged: replace each stored id in merged with visitor_id, so the person’s accounts are one person’s.
  • visitor.split: stop treating visitor_id and split_into as one person; the next event of each account says which of the two it is.
  • visitor.deleted: delete what you store under visitor_id and each of its aliases, as your own deletion process says.
  • event.scored: store or log the answer by its event_id; never wait for it at signup.

Delivery and retries

  • Success is any 2xx answer within 10 seconds. Anything else is a failure: another status, no answer within 10 seconds, no connection, or a redirect, which is not followed.
  • Answer first, then work. Check the signature, store the message or queue a job, and answer 2xx. A slow handler times out and the message is sent again.
  • Eight tries over about a day. The first at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, 8 hours and 8 hours. After the eighth failure the message is Failed: it stays in the log, and goes again only if you press Send again.
  • At least once. A message can arrive more than once, such as when your server did its work but answered too late. Every try has the same id and the same body; only webhook-timestamp and the signature are new. Dedupe on id.
  • Order is not promised. A later change can arrive before an earlier one, as when the first is being retried. Each message carries its own timestamp, and a flag its decided_at: keep the newest.
  • A message can arrive before your own code has stored the id it names, such as a flag.created for an event your signup handler has not saved yet. Answer 2xx and find the id later, or answer with a 5xx to have the message sent again, a minute later and then on the schedule above.
  • Only what happens while an endpoint listens is sent. A change is sent to the endpoints that are enabled and listen for its type when it happens. Nothing earlier is sent to a new endpoint or a newly ticked type.
  • No fixed addresses. Requests come from no published list of addresses: verify the signature instead of allowlisting.

Test and debug

  • Send test, on the endpoint’s page: choose one of its types, and Heedeo sends that type’s sample (above) with "test": true once, with no retries, and shows your server’s answer in place, such as Delivered. Your server answered 200 in 143 ms. or Not delivered. Your server answered 500. A test never makes an endpoint Failing. Skip side effects when test is true: its ids are samples.
  • Deliveries, on the same page: every message sent to the endpoint, newest first, with All and Failed. Each row has when the change happened, the type (with a Test tag), the status (Sending, Delivered, Retrying, Failed), your server’s last answer (its status code, or Timed out, No connection, Domain not found, TLS failed, Not public) and the attempts. Open one for its message id, when it was last tried and will be next, the response (status, time taken and the first 1,000 characters your server answered) and the payload exactly as sent.
  • Send again, on an opened delivery: sends that message now with the same id, signed afresh, whatever its status, unless it is being sent already. A real message that fails again goes back on its schedule while it has tries left. On a paused or disabled endpoint only a test can be sent again: Resume or Enable it first.
  • The log keeps each message for 30 days. Deleting an endpoint deletes its log.
  • Send test and Send again are an owner’s; members can read the log.

Failing, disabled and paused endpoints

StateWhenWhat is sentHow it ends
Failingdeliveries have failed since a date, and none has succeeded sinceeach message is still tried for a day; the endpoint’s page says when it will be turned off if deliveries are still failing, and Settings’ Webhooks row counts itone successful delivery, a Send test that passes, or a new URL. A failure after a day with nothing sent starts a new count
Disabledafter 3 days of failed deliveries, Heedeo turns the endpoint off. An endpoint with nothing to send stays on: only a failure in the last day turns it offnothing: the messages waiting for a try are Failed, and changes made while it is disabled are never sent, not even after you turn it back onfix your server, check it with Send test, then press Enable, which starts the endpoint afresh
Pausedyou pressed Pause in the endpoint’s menunothing: changes made while it is paused are not sent, and a message waiting for a retry is FailedResume sends new changes again and starts the endpoint afresh
  • Send again sends any Failed message from the log you still need, once the endpoint is on.
  • The emails: a day into a failing spell, when its first message has failed every try, the workspace’s owners get one email, Webhook deliveries to example.com are failing, with the last answer, the date the endpoint will be turned off and a link to its delivery log. If it is turned off they get one more, Heedeo turned off webhooks to example.com, with what to do. Each goes once, and not to an owner who has turned off alerts on their Email page.
  • Roll secret, in the endpoint’s menu, makes a new signing secret, and the current one stops working at once. Until your server has the new one it refuses Heedeo’s requests, and each is retried on its schedule (Delivery and retries, above), so deploy the new secret soon after you roll it. Send again sends any message that ran out of tries meanwhile.