[{"data":1,"prerenderedAt":267},["ShallowReactive",2],{"doc:\u002Fdocs\u002Fwebhooks":3},{"page":4,"toc":249,"updated":266},{"path":5,"title":6,"seoTitle":7,"description":8,"blocks":9},"\u002Fdocs\u002Fwebhooks","Webhooks","Webhooks: Receive Identification Results on Your Server","Receive identifications, high-risk visitors, refusals, billing changes and daily usage as signed HTTPS requests to your server, the moment they happen.",[10,13,20,22,27,48,51,53,94,98,105,108,110,126,128,131,133,136,158,164,167,169,186,188,197,201,204,206,208,210,212,215,217,222,225,227,233,236],{"type":11,"text":12},"p","Webhooks are the server API in reverse: instead of reading events from Fingerly, Fingerly sends them to an HTTPS endpoint on your server as they happen. Use them to:",{"type":14,"items":15},"list",[16,17,18,19],"Keep your own copy of every identification for as long as you need it. Fingerly keeps events readable for 30 days.","React to high-risk visitors in your fraud tooling without polling.","Alert on refused requests before they become an outage.","Reconcile usage and billing with your own records.",{"type":11,"text":21},"Webhooks are delivered asynchronously and add no latency to identification.",{"type":23,"level":24,"text":25,"id":26},"heading",2,"Events","events",{"type":28,"columns":29,"rows":32},"table",[30,31],"Event","Sent when",[33,36,39,42,45],[34,35],"[`identification.completed`](\u002Freference\u002Fwebhooks\u002Fidentification-completed)","Any identification finishes.",[37,38],"[`visitor.suspect`](\u002Freference\u002Fwebhooks\u002Fvisitor-suspect)","An identification reaches the `high` level.",[40,41],"[`identification.refused`](\u002Freference\u002Fwebhooks\u002Fidentification-refused)","An identify request is refused.",[43,44],"[`billing.status_changed`](\u002Freference\u002Fwebhooks\u002Fbilling-status-changed)","Your organization starts or stops accepting traffic.",[46,47],"[`usage.daily_settled`](\u002Freference\u002Fwebhooks\u002Fusage-daily-settled)","A day of usage is settled.",{"type":23,"level":24,"text":49,"id":50},"Implement a handler","implement-a-handler",{"type":11,"text":52},"Create a route that accepts a `POST` with a JSON body, verifies the signature, hands the event to your own queue, and returns `2xx` quickly.",{"type":54,"samples":55},"code",[56,60,63,67,70,74,78,82,86,90],{"label":57,"lang":58,"code":59},"Next.js","ts","\u002F\u002F app\u002Fwebhooks\u002Ffingerly\u002Froute.ts\nimport { verifyWebhook } from '@fingerly\u002Fnode'\n\nexport async function POST(request: Request) {\n  const payload = await request.text()\n\n  const valid = verifyWebhook({\n    secret: process.env.FINGERLY_WEBHOOK_SECRET!,\n    payload,\n    timestamp: request.headers.get('x-fingerly-timestamp'),\n    signature: request.headers.get('x-fingerly-signature'),\n  })\n  if (!valid) return new Response('invalid signature', { status: 400 })\n\n  const event = JSON.parse(payload)\n  await queue.add(event.id, event)   \u002F\u002F deduplicate on event.id\n\n  return new Response(null, { status: 204 })\n}",{"label":61,"lang":58,"code":62},"Node.js","import express from 'express'\nimport { verifyWebhook } from '@fingerly\u002Fnode'\n\napp.post('\u002Fwebhooks\u002Ffingerly', express.raw({ type: 'application\u002Fjson' }), async (req, res) => {\n  const valid = verifyWebhook({\n    secret: process.env.FINGERLY_WEBHOOK_SECRET!,\n    payload: req.body,\n    timestamp: req.get('x-fingerly-timestamp'),\n    signature: req.get('x-fingerly-signature'),\n  })\n  if (!valid) return res.sendStatus(400)\n\n  const event = JSON.parse(req.body.toString('utf8'))\n  await queue.add(event.id, event)   \u002F\u002F deduplicate on event.id\n  res.sendStatus(204)\n})",{"label":64,"lang":65,"code":66},"Python","python","from flask import Flask, abort, request\nfrom fingerly import verify_webhook\n\n@app.post(\"\u002Fwebhooks\u002Ffingerly\")\ndef fingerly_webhook():\n    payload = request.get_data()\n    if not verify_webhook(\n        secret=os.environ[\"FINGERLY_WEBHOOK_SECRET\"],\n        payload=payload,\n        timestamp=request.headers.get(\"x-fingerly-timestamp\"),\n        signature=request.headers.get(\"x-fingerly-signature\"),\n    ):\n        abort(400)\n\n    event = json.loads(payload)\n    queue.enqueue(event[\"id\"], event)   # deduplicate on the event ID\n    return \"\", 204",{"label":68,"lang":65,"code":69},"Python (async)","from fastapi import FastAPI, HTTPException, Request, Response\nfrom fingerly import verify_webhook\n\n@app.post(\"\u002Fwebhooks\u002Ffingerly\", status_code=204)\nasync def fingerly_webhook(request: Request) -> Response:\n    payload = await request.body()\n    if not verify_webhook(\n        secret=os.environ[\"FINGERLY_WEBHOOK_SECRET\"],\n        payload=payload,\n        timestamp=request.headers.get(\"x-fingerly-timestamp\"),\n        signature=request.headers.get(\"x-fingerly-signature\"),\n    ):\n        raise HTTPException(status_code=400)\n\n    event = json.loads(payload)\n    await queue.enqueue(event[\"id\"], event)\n    return Response(status_code=204)",{"label":71,"lang":72,"code":73},"Go","go","func fingerlyWebhook(w http.ResponseWriter, r *http.Request) {\n    body, err := io.ReadAll(r.Body)\n    if err != nil {\n        http.Error(w, \"unreadable body\", http.StatusBadRequest)\n        return\n    }\n    if !fingerly.VerifyWebhook(\n        os.Getenv(\"FINGERLY_WEBHOOK_SECRET\"),\n        body,\n        r.Header.Get(\"X-Fingerly-Timestamp\"),\n        r.Header.Get(\"X-Fingerly-Signature\"),\n    ) {\n        http.Error(w, \"invalid signature\", http.StatusBadRequest)\n        return\n    }\n\n    var event fingerly.WebhookEvent\n    _ = json.Unmarshal(body, &event)\n    enqueue(event.ID, body)   \u002F\u002F deduplicate on the event ID\n    w.WriteHeader(http.StatusNoContent)\n}",{"label":75,"lang":76,"code":77},"Java","java","@PostMapping(\"\u002Fwebhooks\u002Ffingerly\")\npublic ResponseEntity\u003CVoid> receive(\n        @RequestBody byte[] body,\n        @RequestHeader(\"x-fingerly-timestamp\") String timestamp,\n        @RequestHeader(\"x-fingerly-signature\") String signature) {\n\n    if (!Webhooks.verify(webhookSecret, body, timestamp, signature)) {\n        return ResponseEntity.badRequest().build();\n    }\n\n    WebhookEvent event = Webhooks.parse(body);\n    events.enqueue(event.getId(), body);   \u002F\u002F deduplicate on the event ID\n    return ResponseEntity.noContent().build();\n}",{"label":79,"lang":80,"code":81},".NET","csharp","app.MapPost(\"\u002Fwebhooks\u002Ffingerly\", async (HttpRequest request, IEventQueue queue) =>\n{\n    using var reader = new StreamReader(request.Body);\n    var body = await reader.ReadToEndAsync();\n\n    var valid = FingerlyWebhook.Verify(\n        secret: builder.Configuration[\"Fingerly:WebhookSecret\"]!,\n        payload: body,\n        timestamp: request.Headers[\"x-fingerly-timestamp\"],\n        signature: request.Headers[\"x-fingerly-signature\"]);\n    if (!valid) return Results.BadRequest();\n\n    var ev = FingerlyWebhook.Parse(body);\n    await queue.EnqueueAsync(ev.Id, body);   \u002F\u002F deduplicate on the event ID\n    return Results.NoContent();\n});",{"label":83,"lang":84,"code":85},"PHP","php","\u003C?php\n\nuse Fingerly\\Webhook;\nuse Illuminate\\Http\\Request;\n\nRoute::post('\u002Fwebhooks\u002Ffingerly', function (Request $request) {\n    $valid = Webhook::verify(\n        secret: config('services.fingerly.webhook_secret'),\n        payload: $request->getContent(),\n        timestamp: $request->header('x-fingerly-timestamp'),\n        signature: $request->header('x-fingerly-signature'),\n    );\n    abort_unless($valid, 400);\n\n    ProcessFingerlyEvent::dispatch($request->json()->all());\n    return response()->noContent();\n});",{"label":87,"lang":88,"code":89},"Ruby","ruby","class FingerlyWebhooksController \u003C ActionController::API\n  def create\n    payload = request.raw_post\n    valid = Fingerly::Webhook.verify(\n      secret: ENV.fetch(\"FINGERLY_WEBHOOK_SECRET\"),\n      payload: payload,\n      timestamp: request.headers[\"x-fingerly-timestamp\"],\n      signature: request.headers[\"x-fingerly-signature\"],\n    )\n    return head :bad_request unless valid\n\n    event = JSON.parse(payload)\n    FingerlyEventJob.perform_later(event)   # deduplicate on event[\"id\"]\n    head :no_content\n  end\nend",{"label":91,"lang":92,"code":93},"Rust","rust","async fn fingerly_webhook(State(state): State\u003CAppState>, headers: HeaderMap, body: Bytes) -> StatusCode {\n    let header = |name| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default();\n\n    if !fingerly::webhook::verify(\n        &state.webhook_secret,\n        &body,\n        header(\"x-fingerly-timestamp\"),\n        header(\"x-fingerly-signature\"),\n    ) {\n        return StatusCode::BAD_REQUEST;\n    }\n\n    let event: fingerly::WebhookEvent = serde_json::from_slice(&body).unwrap();\n    state.queue.enqueue(event.id.clone(), body).await;   \u002F\u002F deduplicate on the event ID\n    StatusCode::NO_CONTENT\n}",{"type":23,"level":95,"text":96,"id":97},3,"Responses and timeouts","responses-and-timeouts",{"type":14,"items":99},[100,101,102,103,104],"Any `2xx` response completes the delivery.","`410 Gone` stops the delivery permanently, without retries.","Any other response, a timeout or a connection failure is retried.","Fingerly waits 10 seconds for a response. Acknowledge first and do slow work from your own queue.","Redirects are not followed. Register the final URL.",{"type":23,"level":95,"text":106,"id":107},"Retries","retries",{"type":11,"text":109},"A failed delivery is attempted up to six times in total. The retries come after:",{"type":28,"columns":111,"rows":118},[112,113,114,115,116,117],"Retry","1","2","3","4","5",[119],[120,121,122,123,124,125],"Delay after the previous attempt","30 seconds","2 minutes","10 minutes","1 hour","6 hours",{"type":11,"text":127},"After the last attempt fails, the delivery is marked failed. An endpoint whose deliveries keep failing is shown as **failing** in the dashboard, and keeps receiving new events.",{"type":23,"level":95,"text":129,"id":130},"Duplicates and ordering","duplicates-and-ordering",{"type":11,"text":132},"Delivery is at least once: when an outcome is ambiguous, such as a timeout after your server processed the request, the same event can arrive again. Its `id` never changes, so deduplicate on it. Events can arrive out of order; use `created_at` when order matters.",{"type":23,"level":24,"text":134,"id":135},"Register an endpoint","register-an-endpoint",{"type":137,"steps":138},"steps",[139,144,153],{"title":140,"blocks":141},"Open Webhooks",[142],{"type":11,"text":143},"In the dashboard, go to **Integration > Webhooks** and add an endpoint.",{"title":145,"blocks":146},"Configure it",[147],{"type":14,"items":148},[149,150,151,152],"**URL**: an `https:\u002F\u002F` URL that resolves to a public address.","**Environment**: **Live** receives production traffic; **Test** receives staging and development traffic.","**Events**: the event types to receive.","**Description**: optional, to tell endpoints apart.",{"title":154,"blocks":155},"Save the signing secret",[156],{"type":11,"text":157},"The signing secret, `whsec_…`, is shown once, when the endpoint is created. Store it in your server's secret manager.",{"type":14,"items":159},[160,161,162,163],"An organization can have up to 10 endpoints across both environments.","Owners, admins and developers can manage webhooks.","`billing.status_changed` is available to live endpoints only.","Pause an endpoint during maintenance on your side. A paused endpoint is not sent new events; deliveries already queued for it wait and are sent when you resume it.",{"type":23,"level":24,"text":165,"id":166},"Verify the signature","verify-the-signature",{"type":11,"text":168},"Every delivery is signed with the endpoint's secret. Verify it before you parse or act on the body: anyone can send a request to a public URL.",{"type":28,"columns":170,"rows":173},[171,172],"Header","Value",[174,177,180,183],[175,176],"`X-Fingerly-Timestamp`","Unix seconds when the attempt was signed.",[178,179],"`X-Fingerly-Signature`","`sha256=` and the lowercase hex HMAC-SHA256 of `timestamp.body`. During a [secret rotation](#rotate-a-secret), one signature per secret, separated by commas.",[181,182],"`X-Fingerly-Event-ID`","The event ID, for deduplication.",[184,185],"`X-Fingerly-Event-Type`","The event type, for routing.",{"type":11,"text":187},"Every server SDK's helper does this for you, as in the handlers above. Without an SDK, the check is one HMAC:",{"type":54,"samples":189},[190,194],{"label":191,"lang":192,"code":193},"Pseudocode","text","signed   = timestamp + \".\" + raw_body\nexpected = hex(hmac_sha256(key = signing_secret, message = signed))\n\nvalid = any(\n          constant_time_equal(expected, candidate without \"sha256=\")\n          for candidate in signature split on \",\"\n        )\n    and abs(now - timestamp) \u003C= 300 seconds",{"label":195,"lang":58,"code":196},"Node.js (no SDK)","import { createHmac, timingSafeEqual } from 'node:crypto'\n\nfunction verify(secret: string, body: Buffer, timestamp: string, signature: string) {\n  if (Math.abs(Date.now() \u002F 1000 - Number(timestamp)) > 300) return false\n\n  const expected = createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex')\n  \u002F\u002F During a secret rotation the header holds one signature per secret.\n  return signature.split(',').some((candidate) => {\n    const value = candidate.trim()\n    if (!value.startsWith('sha256=')) return false\n    const received = value.slice('sha256='.length)\n    return expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))\n  })\n}",{"type":198,"tone":199,"text":200},"callout","warning","Compute the signature over the raw body bytes. Most frameworks parse JSON before your handler runs; configure the route to give you the raw body instead.",{"type":23,"level":24,"text":202,"id":203},"Rotate a secret","rotate-a-secret",{"type":11,"text":205},"In **Integration > Webhooks**, open the endpoint's menu and choose **Rotate secret**. The new secret is shown once. Choose how long the old secret stays valid, from immediately up to seven days; the default is 24 hours.",{"type":11,"text":207},"Until then, every delivery carries two signatures in `X-Fingerly-Signature`, separated by a comma: the new secret's first, then the old one's. Accept a delivery when any of them verifies, as the SDK helpers and the samples above do, and deploy the new secret at any point in the window.",{"type":198,"tone":199,"text":209},"A verifier that compares the whole header against one signature rejects every delivery during the window. Update it before you rotate.",{"type":11,"text":211},"Rotating again before the window ends ends it: only the secret being replaced stays valid alongside the new one.",{"type":23,"level":24,"text":213,"id":214},"Delivery history","delivery-history",{"type":11,"text":216},"**Integration > Webhooks** lists every delivery attempt from the last 30 days with its event, status (`delivered`, `retrying` or `failed`), response code, duration and attempt number, for live and test endpoints.",{"type":14,"items":218},[219,220,221],"**Redeliver** sends an event from the history to the same endpoint again, with the same `id`. It is one attempt, and it does not affect the retries of the original delivery.","**Send test event** sends a signed [`webhook.test`](\u002Freference\u002Fwebhooks\u002Fenvelope#event-types) event to one endpoint, in one attempt, so you can check your URL and signature verification.","A failed test or redelivery does not mark the endpoint as failing. Neither is available while the endpoint is paused, and only one at a time can be queued for an endpoint.",{"type":23,"level":24,"text":223,"id":224},"Test locally","test-locally",{"type":11,"text":226},"Webhook URLs must be public, so expose your local server with a tunnel such as `cloudflared` or `ngrok`, register the tunnel's URL on a **Test** endpoint, and identify with a development key.",{"type":54,"samples":228},[229],{"label":230,"lang":231,"code":232},"Terminal","bash","cloudflared tunnel --url http:\u002F\u002Flocalhost:3000",{"type":198,"tone":234,"text":235},"note","Test endpoints only ever receive staging and development traffic, so a local receiver never sees production events.",{"type":237,"columns":24,"cards":238},"cards",[239,244],{"title":240,"text":241,"href":242,"icon":243},"Event envelope","Headers, signature and envelope fields.","\u002Freference\u002Fwebhooks\u002Fenvelope","webhook",{"title":245,"text":246,"href":247,"icon":248},"Reading events","Backfill or reconcile with the server API.","\u002Fdocs\u002Freading-events","server",[250,251,252,253,254,255,256,258,260,262,263,264,265],{"id":26,"text":25,"level":24},{"id":50,"text":49,"level":24},{"id":97,"text":96,"level":95},{"id":107,"text":106,"level":95},{"id":130,"text":129,"level":95},{"id":135,"text":134,"level":24},{"id":257,"text":140,"level":95},"step-open-webhooks",{"id":259,"text":145,"level":95},"step-configure-it",{"id":261,"text":154,"level":95},"step-save-the-signing-secret",{"id":166,"text":165,"level":24},{"id":203,"text":202,"level":24},{"id":214,"text":213,"level":24},{"id":224,"text":223,"level":24},"2026-09-17T16:57:29.000Z",1789667797514]