Documentation menu
Integrations

Webhooks

When a push approval resolves, U2 Secured POSTs a signed JSON event to your server so your backend learns the outcome without polling.

What you receive

On every resolution we send a POST with a JSON body and two headers: X-U2Auth-Event (always "approval.resolved") and X-U2Auth-Signature (an HMAC you verify, below). The Content-Type is application/json.

Where it is sent

The event is delivered to the webhook_url you pass on each push-approval request from the SDK. If you omit it, U2 Secured falls back to the app’s Webhook URL (set when you create the app or on the app’s Webhook tab). Webhooks are optional — leave the URL blank to disable them.

Event payload

The body is a flat JSON object:

approval.resolved
{
  "approval_id": "ap_3f2a9c…",
  "status": "approved",
  "reason": "approved",
  "app_id": "app_8c1d4b…",
  "user_identifier": "ada@example.com",
  "resolved_at": "2026-06-26T18:04:11Z"
}

Status values

status (and the more detailed reason) is one of:

  • approved — the user tapped approve (and matched the number, when required).
  • denied — the user rejected the request.
  • expired — no response before the approval timed out.

Verify the signature

X-U2Auth-Signature is "t=<unix-timestamp>,v1=<hex>", where v1 is HMAC-SHA256 of "<t>." + the raw request body, keyed with your app’s webhook secret (Dashboard → your app → Webhook → Reveal signing secret). Compute it over the RAW bytes — parse the JSON only after the signature checks out:

verify.js
import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.U2_WEBHOOK_SECRET;
const TOLERANCE_SEC = 300;                                         // same as the SDKs

// Capture the RAW body — needed for the signature.
app.post("/webhooks/u2secured", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("X-U2Auth-Signature") ?? "";              // "t=<unix>,v1=<hex>"
  const { t, v1 } = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  if (!t || !v1) return res.sendStatus(401);                       // malformed header

  const expected = crypto.createHmac("sha256", SECRET)
    .update(`${t}.${req.body}`)                                  // "<t>." + raw body
    .digest("hex");

  // timingSafeEqual THROWS on a length mismatch, and v1 comes from the caller —
  // compare lengths first or a short v1 crashes the handler with a 500.
  const a = Buffer.from(v1, "utf8");
  const b = Buffer.from(expected, "utf8");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  // Signature alone does not stop a replay: the bytes stay valid forever.
  // Reject anything too far from your own clock. NaN must fail closed —
  // "NaN > TOLERANCE_SEC" is false, so a non-numeric t would sail through.
  const skew = Math.abs(Math.floor(Date.now() / 1000) - Number(t));
  if (!Number.isFinite(skew) || skew > TOLERANCE_SEC) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString());                   // safe to parse now
  // event.status is "approved" | "denied" | "expired"
  res.sendStatus(200);                                             // ack fast
});

Each SDK ships the same check as a one-line helper, so you rarely need to write the comparison yourself.

main.go
event, err := u2auth.VerifyWebhook(rawBody, r.Header.Get("X-U2Auth-Signature"), webhookSecret)
if err != nil {
    http.Error(w, "bad signature", http.StatusBadRequest)
    return
}
// event.ApprovalID, event.Status

Use the raw body

Re-serializing the parsed JSON can change bytes (key order, spacing) and break the HMAC. Verify against the exact bytes you received, then parse.

Reject replays

A valid signature only proves the body came from us — it does not prove it is fresh. The signed timestamp t is what makes a replay detectable: compare it against your own clock and reject anything outside a tolerance window. Every SDK already does this, defaulting to 300 seconds, and lets you narrow it (WithTolerance in Go, toleranceSec in Node and Python). If you verify by hand, do the same check — the example above shows it. Retries stop 31 seconds after the first attempt, so a window much wider than the default buys you nothing but a longer replay opportunity.

Fail closed on a timestamp you cannot parse

A non-numeric t yields NaN, and every comparison against NaN is false — so a naive "skew > tolerance" test accepts it. Check that the skew is a finite number before you compare it.

Bind the event to your own state

A fresh, correctly signed event proves the bytes are ours and recent. It does not prove the event concerns a request you made, and nothing in the payload is a substitute for your own records. Before you act on one:

  • Match the secret to the app. Secrets are per-app, so one endpoint serving several of your apps must know which app a delivery is for before it can verify it — give each app its own callback path and pick the secret from the path. Verifying against whichever of your secrets happens to work lets one app’s key authorise a body naming another. The wire payload does carry app_id, but the SDK helpers do not surface it (they return approval_id, status, reason and resolved_at), so read it from the raw body yourself if you need to cross-check.
  • Look up approval_id in your own records, and ignore ids you do not recognise. This is also what makes at-least-once delivery safe: the duplicate finds an approval you have already resolved.
  • Take the identity from your record, not from the body. user_identifier tells you who the request was for; your own pending-approval row is what ties that request to a session.

Treat resolution as terminal and idempotent — once an approval is approved, denied or expired, ignore any later event for it. And if you need certainty, or you missed the delivery entirely, GET /push/{id}/status is authoritative: the webhook is a notification, not the system of record.

A signature authenticates the app, not the user

Never start a session from a user_identifier read off the payload. The signature proves the event came from us about one of your apps — it says nothing about which browser or login attempt is waiting on it. That binding is yours to hold.

Delivery & retries

Delivery is asynchronous and best-effort: up to 4 attempts with 0s, 1s, 5s, then 25s backoff. A 5xx or connection failure is retried; any other response stops retries. Respond 2xx quickly once you have stored the event, and treat delivery as at-least-once (deduplicate on approval_id).

Where we deliver, and what we refuse

Webhooks travel outward: U2 Secured connects to the URL you supply, so it is your endpoint that must be reachable from the public internet — nothing arrives inbound at auth.u2secured.com, and its reachability has no bearing on whether your webhooks work. The sender is deliberately strict about where it will connect:

  • Private and loopback destinations are refused outright — localhost, 127.0.0.1, RFC1918 ranges, link-local, CGNAT (100.64.0.0/10), IPv6 ULA and NAT64. This is an SSRF guard: the request is never made, so a URL pointing anywhere internal receives nothing at all.
  • Redirects are not followed. A URL that answers 301 to your real handler fails — register the final address, not one that bounces to it. The apex-redirects-to-www case is the common one.
  • The connection is dialled against the IP we validated, so a DNS answer that changes between the check and the connection cannot redirect the delivery.
  • Five-second timeout per attempt, then the retry ladder above.
  • http:// is accepted as well as https://, but the payload names your user, so prefer https — the signature authenticates the body, it does not conceal it.

Your laptop is not reachable from us

A local endpoint cannot receive deliveries, because the loopback and private-range rules refuse it before a connection is attempted — the symptom is complete silence rather than an error. Use a public tunnel during development and point webhook_url at the tunnel’s https address.

See what we sent

Every attempt is recorded, so you never have to guess whether a delivery left our side. The log returns the most recent attempts for an app, newest first (limit defaults to 50 and is capped at 100), as {items, next_cursor}: pass next_cursor back as ?cursor= for the following page, and an empty one means you have reached the end. Paging is keyset rather than offset, so a delivery arriving while you read cannot push a row you have already seen onto the next page. status is our view of the attempt; response_code is what your endpoint returned, and error is a plain-language reason drawn from a fixed set (failure_kind) rather than a raw transport error. attempt_log holds one entry per attempt — up to four — each with its own code, reason, duration and up to 2KB of your endpoint’s response body, so a delivery that failed four times shows whether it failed the same way each time. A row with attempts above 1 was retried; response_code null with an error set means we never got a response at all.

webhook-deliveries
GET /api/v1/dev/apps/{app_id}/webhook-deliveries?limit=50

{
  "items": [
    {
      "id": "whd_7c2f…",
      "approval_id": "ap_3f2a9c…",
      "url": "https://example.com/webhooks/u2secured",
      "status": "failed",
      "approval_status": "approved",
      "attempts": 2,
      "response_code": 503,
      "failure_kind": "http_error",
      "error": "Your endpoint returned an error status.",
      "attempt_log": [
        { "number": 1, "at": "2026-06-26T18:04:11Z", "response_code": 503,
          "failure_kind": "http_error", "duration_ms": 120,
          "response_body": "upstream unavailable" },
        { "number": 2, "at": "2026-06-26T18:04:12Z", "response_code": 503,
          "failure_kind": "http_error", "duration_ms": 98,
          "response_body": "upstream unavailable" }
      ],
      "created_at": "2026-06-26T18:04:11Z",
      "completed_at": "2026-06-26T18:04:13Z"
    }
  ],
  "next_cursor": "MjAyNi0wNi0yNlQxODowNDoxMVp8d2hkXzdjMmY"
}