trustyourinbox

Receiving webhook events for critical issues and DNS fixes

Outbound webhooks push events to you instead of making you poll. Register an HTTPS endpoint, and trustyourinbox POSTs a signed JSON payload the instant a critical issue is detected or a one-click DNS fix lands, so your own systems can page on-call, open a ticket, or post to Slack in real time. Here's how to add an endpoint, verify that a delivery really came from us, what the payload looks like, and how retries and de-duplication work.

What outbound webhooks give you

A webhook is a small HTTP request we send to a URL you own, the moment something worth reacting to happens. Instead of checking the dashboard or calling the API on a timer, your systems find out immediately and can act: page the on-call engineer, open a ticket, drop a message in a Slack channel, or kick off a runbook.

We send an event when:

  • a critical issue is detected on one of your domains (a crit-severity anomaly, like alignment collapsing or a DKIM key going missing),
  • a one-click DNS fix is applied to your DNS,
  • a DNS fix is reverted (an undo, or a manual rollback),
  • and a DNS fix fails to apply, so you know a change you expected didn't land.

Outbound webhooks are included from the Pro plan up. You manage them in the dashboard under Settings, then Integrations, then Webhooks.

Step 1: Add an endpoint

You'll need a URL that can receive an HTTP POST. It must be HTTPS and resolve to a public address. In Settings, then Integrations, then Webhooks, click Add endpoint, paste the URL, and save.

We show you a signing secret once, at creation. Copy it now and store it like a password. It's what lets your code prove a request really came from us (Step 2). If you lose it, use Rotate secret on the endpoint to get a new one. Each endpoint has its own secret.

Every delivery we send carries three headers your handler should read:

  • X-TYI-Signature: the HMAC signature, formatted as sha256=<hex>.
  • X-TYI-Timestamp: the unix time (in seconds) the delivery was signed.
  • X-TYI-Event-Id: the id of this event. Every retry of it carries the same id, so de-duplicate on it.

Step 2: Verify the signature

Anyone who learns your URL could POST to it, so never trust a payload until you've checked the signature. We sign each delivery by computing an HMAC-SHA256 over the string {timestamp}.{rawBody} (the X-TYI-Timestamp value, a literal dot, then the exact request body) using your endpoint's signing secret. You recompute the same value and compare it, in constant time, to the X-TYI-Signature header.

The one thing to get right: hash the raw request body, the exact bytes we sent, before any framework parses or re-serializes the JSON. Re-serializing changes whitespace and key order, and the signature won't match. Here's the check in Node:

import crypto from "node:crypto";

// The signing secret shown once when you created the endpoint.
const SIGNING_SECRET = process.env.TYI_WEBHOOK_SECRET;

// rawBody MUST be the exact bytes we sent, before any JSON parse or
// re-serialize. Read it as a raw string/buffer, not the parsed object.
function isFromTrustYourInbox(headers, rawBody) {
  const signature = headers["x-tyi-signature"]; // "sha256=<hex>"
  const timestamp = headers["x-tyi-timestamp"]; // unix seconds

  const expected =
    "sha256=" +
    crypto
      .createHmac("sha256", SIGNING_SECRET)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

  // Constant-time compare so a mismatch can't be timed.
  const a = Buffer.from(signature || "");
  const b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

If the comparison fails, respond 401 and drop the request. As a second layer, you can reject deliveries whose X-TYI-Timestamp is more than a few minutes old to blunt replay attempts.

The payload

Every delivery is a JSON object at version 1. Reply 2xx quickly to acknowledge it; if you need to do slow work, enqueue it and return right away. A crit anomaly looks like this:

{
  "version": 1,
  "event": "anomaly.crit",
  "id": "anomaly.crit:dom_5c1e90:low_alignment@2026-07-09T14:03:22.000Z",
  "occurredAt": "2026-07-09T14:03:22.000Z",
  "workspaceId": "ws_3a8f21",
  "data": {
    "domainId": "dom_5c1e90",
    "domainName": "acme.com",
    "severity": "crit",
    "anomalyType": "low_alignment",
    "conditionKey": "anomaly.crit:dom_5c1e90:low_alignment",
    "title": "Alignment dropped below 85% for acme.com",
    "body": "Aligned mail fell to 78% over the last 24 hours. A sending source may have broken authentication.",
    "url": "https://app.trustyourinbox.com/dashboard/domains/dom_5c1e90"
  }
}

The top-level fields are stable across every event type:

  • version: the payload schema version. It's 1 today; we'll bump it if the shape ever changes so your handler can branch on it.
  • event: which of the ten event types this is (below).
  • id: the event id, the same value as the X-TYI-Event-Id header. It names one event: a retry keeps it, a new occurrence of the same condition gets a new one. Use it to de-duplicate, and treat it as an opaque string.
  • occurredAt: an ISO 8601 timestamp for when the event happened.
  • workspaceId: the workspace the event belongs to.
  • data: the event detail: domainId, domainName, severity, an optional anomalyType (present on anomaly.crit), a plain-English title and body, a url that deep-links to the relevant page in the app, and, on events about a condition that can happen more than once, a conditionKey (below).

The events we send

  • anomaly.crit: a critical issue was detected on a domain. data.anomalyType names which check fired.
  • dns_fix.applied: a one-click DNS fix was written to your DNS.
  • dns_fix.reverted: a DNS fix was rolled back, either through Undo or manually.
  • dns_fix.failed: a DNS fix we attempted did not apply.
  • diagnosis.completed: a forwarded bounce or spam-foldered message was diagnosed and the verdict is ready.
  • diagnosis.recovery_confirmed: after a diagnosis-driven fix, the recovery watch saw your mail passing again.
  • enforcement.regression_detected: after a policy tightening, the post-flip watch saw receivers enforcing against your own mail.
  • enforcement.watch_all_clear: the post-flip watch ended with real volume and no regression.
  • mandate.gap_detected: the Mandate Monitor probe found a bulk stream violating a sender-mandate requirement.
  • mandate.gap_resolved: every previously failing stream now grades clean.

The three dns_fix.* events are the tail end of the same safety pipeline every one-click fix runs through. If you want the full picture of what happens between the button and your DNS, see how we update DNS records safely.

Delivery, retries, and auto-disable

Delivery is at-least-once. If your endpoint times out, errors, or returns a non-2xx status, we retry with increasing backoff, up to four attempts (about a minute, then five minutes, then fifteen). Because a delivery can arrive more than once (a retry after your server already processed it but the acknowledgement was lost), your handler must be idempotent: de-duplicate on X-TYI-Event-Id. Every retry of an event carries the same id, so recording the ids you've already handled and skipping repeats is all it takes. A new event always gets a new id: a critical issue that clears and comes back, a critical issue still open when its 14-day reminder is due, a mandate gap that reopens, a further enforcement regression, or an undo that fails again after a retry.

Events about a condition that can happen more than once also carry data.conditionKey, the same value on every occurrence: a critical issue on one domain and check, a mandate gap and its resolution on one domain, or the notices of one enforcement watch. Group on it when you want one ticket per condition rather than one per event. On those events the id reads <conditionKey>@<ISO instant>, the instant being when that occurrence fired; it is still an opaque string to your code, so group on conditionKey rather than parsing the id.

A few delivery rules worth knowing. We don't follow redirects (a 3xx counts as a failure, so point us straight at the final URL), and we only connect to public addresses. Endpoints that resolve to private or internal ranges are refused, so a webhook can't be pointed at something inside our own network.

If an endpoint keeps failing, we stop wasting deliveries on it. After 20 consecutive failures, the endpoint is automatically disabled. Fix the receiver, then click Enable on the endpoint in Settings, then Integrations, then Webhooks. Nothing is deleted; it just sends nothing until you turn it back on.

Testing and the delivery log

Every endpoint has a Send test button that delivers a sample event, so you can wire up and verify your handler before a real one ever fires. Each endpoint also keeps a delivery log: what we sent, the response we got back, and whether it succeeded or is being retried. If a real event doesn't show up where you expect, the log is the first place to look. It tells you whether we delivered it and what your server said.

Doing it from the API or an AI assistant

Everything above is also available programmatically. The REST API manages endpoints (POST /v1/webhooks to create one, plus the usual list, read, and delete), and there are MCP tools like create_webhook_endpoint so an AI assistant can set one up on your behalf. Both are documented in the API reference.

Troubleshooting

Every delivery fails the signature check. Almost always this is the raw-body problem: you're hashing a re-serialized version of the JSON instead of the exact bytes we sent. Capture the raw body before your framework parses it, and confirm you're signing {timestamp}.{rawBody} with a literal dot between them, using the right endpoint's secret.

The endpoint went quiet. Check whether it was auto-disabled after 20 consecutive failures. Open its delivery log to see what your server returned, fix that, then click Enable on the endpoint.

We won't accept my URL. It has to be HTTPS and resolve to a public address. A http:// URL, a private or internal host, or one that only answers behind a redirect will be refused.

I'm getting the same event twice. That's expected under at-least-once delivery. De-duplicate on X-TYI-Event-Id, which every retry of one event keeps. A repeat of the same condition later is a new event with a new id and the same data.conditionKey.

Still stuck? Open the help bubble in the dashboard and send us a note. We read every one.

Know when something needs you. Where you already work.

We check your domains every six hours. A critical issue goes to the bell, your inbox, a Slack channel, or a signed webhook, with a link to the domain it is about. In-app alerts are on every plan. Email starts on Lite, webhooks on Pro.

Was this page helpful?

Keep reading.

Start with one domain. See it with your own data.

Free for one domain, no card. Add one DNS record and the first reports usually arrive within a day.

  1. Monitoring
  2. Quarantine
  3. Reject