Signed webhooks: verifying Onekey monitor events

The Onekey team4 min read

How a monitor delivery is signed, how to verify it in Python, Node or the shell without getting bitten by JSON re-serialising, and what happens when your endpoint fails.

A monitor saves an API call, re-runs it on a schedule (every 15 minutes up to every 24 hours) and sends a webhook to your URL when its alert rules fire. Anyone can send a request to a public URL, so every delivery is signed. This post shows exactly what is signed, how to check it, and the mistakes that make good signatures look bad.

Monitors are described on the monitors page; this is the verification half of that page, with code that has been run.

What arrives#

A delivery is a POST with a JSON body and these headers:

HeaderValue
Content-Typeapplication/json
User-AgentOnekey-Monitors/1.0
Onekey-EventThe event type.
Onekey-Monitor-IdThe monitor id.
Onekey-Delivery-IdThe body's id. The same on every retry of one event, so use it to de-duplicate.
Onekey-Signaturet=<unix seconds>,v1=<hex>

The signature is an HMAC-SHA256 of the timestamp, a dot, and the raw request body, keyed with the webhook.secret you received when you created the monitor (or at the last rotation):

text
v1 = HMAC-SHA256(secret, "<t>." + raw request body)

Including t in the signed string is what makes a replay window possible: an attacker who captured one delivery cannot change its timestamp without breaking the signature.

The three checks#

  1. Recompute the digest over the raw bytes you received. Not over re-serialised JSON.
  2. Compare in constant time, so the comparison does not leak how many characters matched.
  3. Refuse a t more than 300 seconds from your clock.
# Recompute the digest for a saved body. T is the t= value from the header,
# body.json is the raw body exactly as received. Compare with the v1= value by eye.
T=1759226400
printf '%s.' "$T" | cat - body.json | openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1

The Python and Node versions above compute the same digest. Signing a body with one and verifying it with the other, and running the openssl line over the same bytes, produced identical hex, and both verifiers accepted a fresh signature, rejected a body changed by one byte, rejected a wrong secret, and rejected a timestamp 10 minutes old. Do that same test against your own handler before you trust it: build a signature with a throwaway secret and send it.

The mistake that breaks everything#

If your framework parses the body before you verify, the check will fail on perfectly good deliveries. A JSON parser followed by a re-serialiser changes spacing and key order, and the digest is over bytes, not over meaning. Read the body as bytes first (request.get_data(), await request.body(), express.raw), verify, and only then parse.

The second mistake is comparing with ==. It works, and it is the version that leaks timing. Use hmac.compare_digest or crypto.timingSafeEqual, as above. Both are in the glossary as constant-time comparison.

What a delivery says#

An event's body carries the event type and ids, plus the monitor, the run, and for monitor.changed a delta and the new data:

json
{
  "run": { "seq": 12, "status": "changed", "content_hash": "...", "previous_hash": "..." },
  "delta": {
    "added": [], "removed": [],
    "changed": [ { "id": "item", "fields": { "entity.stats.stars": { "from": 250835, "to": 250901, "delta": 66, "pct": 0.0263 } } } ],
    "summary": { "added": 0, "removed": 0, "changed": 1 }
  }
}

That excerpt is from the docs' recorded example. Only monitor.changed carries delta, matched_rules and data. A body over 64 KB drops data ("data_omitted": true) and keeps the delta. Send yourself a signed test event at any time with POST /v1/monitors/{id}/webhook/test; it makes one attempt and returns the delivery.

When your endpoint fails#

Success is any 2xx within 5 seconds. A 3xx is a failure, because redirects are never followed; any other status, a connection error or a timeout is too. After a failed attempt the next waits 1 minute, then 5 minutes, 30 minutes, 2 hours and 12 hours, each with 20 percent jitter: at most 6 attempts per event. Each retry is signed afresh, with a new t and the same body, so your replay-window check keeps working on late retries.

After 10 consecutive failed attempts on a monitor, its webhook is switched off. Runs continue and you can still read changed runs and their deltas through the API, but nothing is delivered until you re-enable it with PATCH /v1/monitors/{id} and a webhook.url. Events are not ordered across each other, so use run.seq and Onekey-Delivery-Id.

Where the URL may point#

Because the service makes the request, it has to protect itself from being aimed at internal addresses. A webhook URL is refused with WEBHOOK_URL_REJECTED, at creation and on update, unless it is https on port 443 to a public host: no user info, no numeric host, no localhost or *.internal names, and no private, loopback, link-local or cloud-metadata address in any DNS answer. The host is resolved again at delivery and the connection goes to that exact address, so a DNS change between the check and the connect cannot redirect it. That is a defence against SSRF. It also means you cannot test with a local address: put a public HTTPS tunnel in front of your machine.

Checklist#

  1. Read the raw body as bytes.
  2. Parse t and v1; reject if either is missing.
  3. Reject if t is more than 300 seconds from your clock.
  4. Recompute HMAC-SHA256(secret, t + "." + body) and compare in constant time.
  5. Acknowledge with a 2xx quickly, and do slow work afterwards.
  6. De-duplicate on Onekey-Delivery-Id.
  7. Keep the secret in an environment variable, and rotate it with POST /v1/monitors/{id}/webhook/rotate if it leaks; the new secret is shown once.

Runs are billed like normal calls; read credits and refunds for how. The terms are in the glossary: webhook, webhook signature and delta.