# Webhooks

> Receive identifications, high-risk visitors, refusals, billing changes and daily usage as signed HTTPS requests to your server, the moment they happen.

Last updated: 2026-09-17

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:

- 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.

Webhooks are delivered asynchronously and add no latency to identification.

## Events

| Event | Sent when |
| --- | --- |
| [`identification.completed`](https://docs.fingerly.io/reference/webhooks/identification-completed) | Any identification finishes. |
| [`visitor.suspect`](https://docs.fingerly.io/reference/webhooks/visitor-suspect) | An identification reaches the `high` level. |
| [`identification.refused`](https://docs.fingerly.io/reference/webhooks/identification-refused) | An identify request is refused. |
| [`billing.status_changed`](https://docs.fingerly.io/reference/webhooks/billing-status-changed) | Your organization starts or stops accepting traffic. |
| [`usage.daily_settled`](https://docs.fingerly.io/reference/webhooks/usage-daily-settled) | A day of usage is settled. |

## Implement a handler

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.

```ts Next.js
// app/webhooks/fingerly/route.ts
import { verifyWebhook } from '@fingerly/node'

export async function POST(request: Request) {
  const payload = await request.text()

  const valid = verifyWebhook({
    secret: process.env.FINGERLY_WEBHOOK_SECRET!,
    payload,
    timestamp: request.headers.get('x-fingerly-timestamp'),
    signature: request.headers.get('x-fingerly-signature'),
  })
  if (!valid) return new Response('invalid signature', { status: 400 })

  const event = JSON.parse(payload)
  await queue.add(event.id, event)   // deduplicate on event.id

  return new Response(null, { status: 204 })
}
```

```ts Node.js
import express from 'express'
import { verifyWebhook } from '@fingerly/node'

app.post('/webhooks/fingerly', express.raw({ type: 'application/json' }), async (req, res) => {
  const valid = verifyWebhook({
    secret: process.env.FINGERLY_WEBHOOK_SECRET!,
    payload: req.body,
    timestamp: req.get('x-fingerly-timestamp'),
    signature: req.get('x-fingerly-signature'),
  })
  if (!valid) return res.sendStatus(400)

  const event = JSON.parse(req.body.toString('utf8'))
  await queue.add(event.id, event)   // deduplicate on event.id
  res.sendStatus(204)
})
```

```python Python
from flask import Flask, abort, request
from fingerly import verify_webhook

@app.post("/webhooks/fingerly")
def fingerly_webhook():
    payload = request.get_data()
    if not verify_webhook(
        secret=os.environ["FINGERLY_WEBHOOK_SECRET"],
        payload=payload,
        timestamp=request.headers.get("x-fingerly-timestamp"),
        signature=request.headers.get("x-fingerly-signature"),
    ):
        abort(400)

    event = json.loads(payload)
    queue.enqueue(event["id"], event)   # deduplicate on the event ID
    return "", 204
```

```python Python (async)
from fastapi import FastAPI, HTTPException, Request, Response
from fingerly import verify_webhook

@app.post("/webhooks/fingerly", status_code=204)
async def fingerly_webhook(request: Request) -> Response:
    payload = await request.body()
    if not verify_webhook(
        secret=os.environ["FINGERLY_WEBHOOK_SECRET"],
        payload=payload,
        timestamp=request.headers.get("x-fingerly-timestamp"),
        signature=request.headers.get("x-fingerly-signature"),
    ):
        raise HTTPException(status_code=400)

    event = json.loads(payload)
    await queue.enqueue(event["id"], event)
    return Response(status_code=204)
```

```go Go
func fingerlyWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "unreadable body", http.StatusBadRequest)
        return
    }
    if !fingerly.VerifyWebhook(
        os.Getenv("FINGERLY_WEBHOOK_SECRET"),
        body,
        r.Header.Get("X-Fingerly-Timestamp"),
        r.Header.Get("X-Fingerly-Signature"),
    ) {
        http.Error(w, "invalid signature", http.StatusBadRequest)
        return
    }

    var event fingerly.WebhookEvent
    _ = json.Unmarshal(body, &event)
    enqueue(event.ID, body)   // deduplicate on the event ID
    w.WriteHeader(http.StatusNoContent)
}
```

```java Java
@PostMapping("/webhooks/fingerly")
public ResponseEntity<Void> receive(
        @RequestBody byte[] body,
        @RequestHeader("x-fingerly-timestamp") String timestamp,
        @RequestHeader("x-fingerly-signature") String signature) {

    if (!Webhooks.verify(webhookSecret, body, timestamp, signature)) {
        return ResponseEntity.badRequest().build();
    }

    WebhookEvent event = Webhooks.parse(body);
    events.enqueue(event.getId(), body);   // deduplicate on the event ID
    return ResponseEntity.noContent().build();
}
```

```csharp .NET
app.MapPost("/webhooks/fingerly", async (HttpRequest request, IEventQueue queue) =>
{
    using var reader = new StreamReader(request.Body);
    var body = await reader.ReadToEndAsync();

    var valid = FingerlyWebhook.Verify(
        secret: builder.Configuration["Fingerly:WebhookSecret"]!,
        payload: body,
        timestamp: request.Headers["x-fingerly-timestamp"],
        signature: request.Headers["x-fingerly-signature"]);
    if (!valid) return Results.BadRequest();

    var ev = FingerlyWebhook.Parse(body);
    await queue.EnqueueAsync(ev.Id, body);   // deduplicate on the event ID
    return Results.NoContent();
});
```

```php PHP
<?php

use Fingerly\Webhook;
use Illuminate\Http\Request;

Route::post('/webhooks/fingerly', function (Request $request) {
    $valid = Webhook::verify(
        secret: config('services.fingerly.webhook_secret'),
        payload: $request->getContent(),
        timestamp: $request->header('x-fingerly-timestamp'),
        signature: $request->header('x-fingerly-signature'),
    );
    abort_unless($valid, 400);

    ProcessFingerlyEvent::dispatch($request->json()->all());
    return response()->noContent();
});
```

```ruby Ruby
class FingerlyWebhooksController < ActionController::API
  def create
    payload = request.raw_post
    valid = Fingerly::Webhook.verify(
      secret: ENV.fetch("FINGERLY_WEBHOOK_SECRET"),
      payload: payload,
      timestamp: request.headers["x-fingerly-timestamp"],
      signature: request.headers["x-fingerly-signature"],
    )
    return head :bad_request unless valid

    event = JSON.parse(payload)
    FingerlyEventJob.perform_later(event)   # deduplicate on event["id"]
    head :no_content
  end
end
```

```rust Rust
async fn fingerly_webhook(State(state): State<AppState>, headers: HeaderMap, body: Bytes) -> StatusCode {
    let header = |name| headers.get(name).and_then(|v| v.to_str().ok()).unwrap_or_default();

    if !fingerly::webhook::verify(
        &state.webhook_secret,
        &body,
        header("x-fingerly-timestamp"),
        header("x-fingerly-signature"),
    ) {
        return StatusCode::BAD_REQUEST;
    }

    let event: fingerly::WebhookEvent = serde_json::from_slice(&body).unwrap();
    state.queue.enqueue(event.id.clone(), body).await;   // deduplicate on the event ID
    StatusCode::NO_CONTENT
}
```

### Responses and timeouts

- 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.

### Retries

A failed delivery is attempted up to six times in total. The retries come after:

| Retry | 1 | 2 | 3 | 4 | 5 |
| --- | --- | --- | --- | --- | --- |
| Delay after the previous attempt | 30 seconds | 2 minutes | 10 minutes | 1 hour | 6 hours |

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.

### Duplicates and ordering

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.

## Register an endpoint

### Step 1: Open Webhooks

In the dashboard, go to **Integration > Webhooks** and add an endpoint.

### Step 2: Configure it

- **URL**: an `https://` 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.

### Step 3: Save the signing secret

The signing secret, `whsec_…`, is shown once, when the endpoint is created. Store it in your server's secret manager.

- 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.

## Verify the signature

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.

| Header | Value |
| --- | --- |
| `X-Fingerly-Timestamp` | Unix seconds when the attempt was signed. |
| `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. |
| `X-Fingerly-Event-ID` | The event ID, for deduplication. |
| `X-Fingerly-Event-Type` | The event type, for routing. |

Every server SDK's helper does this for you, as in the handlers above. Without an SDK, the check is one HMAC:

```text Pseudocode
signed   = timestamp + "." + raw_body
expected = hex(hmac_sha256(key = signing_secret, message = signed))

valid = any(
          constant_time_equal(expected, candidate without "sha256=")
          for candidate in signature split on ","
        )
    and abs(now - timestamp) <= 300 seconds
```

```ts Node.js (no SDK)
import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(secret: string, body: Buffer, timestamp: string, signature: string) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false

  const expected = createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex')
  // During a secret rotation the header holds one signature per secret.
  return signature.split(',').some((candidate) => {
    const value = candidate.trim()
    if (!value.startsWith('sha256=')) return false
    const received = value.slice('sha256='.length)
    return expected.length === received.length && timingSafeEqual(Buffer.from(expected), Buffer.from(received))
  })
}
```

> **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.

## Rotate a secret

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.

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.

> **Warning:** A verifier that compares the whole header against one signature rejects every delivery during the window. Update it before you rotate.

Rotating again before the window ends ends it: only the secret being replaced stays valid alongside the new one.

## Delivery history

**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.

- **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`](https://docs.fingerly.io/reference/webhooks/envelope#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.

## Test locally

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.

```bash Terminal
cloudflared tunnel --url http://localhost:3000
```

> **Note:** Test endpoints only ever receive staging and development traffic, so a local receiver never sees production events.

- [Event envelope](https://docs.fingerly.io/reference/webhooks/envelope): Headers, signature and envelope fields.
- [Reading events](https://docs.fingerly.io/docs/reading-events): Backfill or reconcile with the server API.
