> ## Documentation Index
> Fetch the complete documentation index at: https://docs.encorekit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Offer Completed Webhook — Encore Notifies Your Server

> Encore POSTs an offer_completed event to a URL you own the moment a user's offer completion is verified. One event type, HMAC-SHA256 signed, delivered once with no retries. Configure the URL and signing secret in the dashboard under Settings.

## Overview

When one of your users completes an offer and the completion is **verified** on Encore's side, Encore can `POST` an `offer_completed` event to a URL you own. That's the whole feature: one event type, one URL per app, signed with HMAC-SHA256 so you can prove it came from us.

Use it when your server needs to *react* to a completion — grant an in-app reward, credit a balance, mark a task done, kick off an email. You don't have to poll anything and you don't need an SDK; you just host an HTTPS endpoint.

<Note>
  **Direction matters — this is Encore → your server.**

  * **This page** — *outbound.* Encore calls **your** endpoint when an offer completes.
  * **[Webhook Ingestion](/publishers/web/sdk-reference/webhook-ingestion)** — *inbound.* **You** call Encore's endpoint to forward your own billing's subscription lifecycle events in for incrementality (NCL) measurement.

  They are unrelated flows with different headers, different signing strings, and opposite directions. If you're forwarding Stripe subscription events into Encore, you want the other page.
</Note>

<Warning>
  Delivery is **fire-and-forget: there are no retries.** If your endpoint is down, slow, or returns a non-2xx, that event is logged on our side and dropped — it will never be redelivered. Read [Delivery guarantees](#delivery-guarantees) before you make anything financial depend on it.
</Warning>

***

## How it works

```mermaid theme={null}
sequenceDiagram
    participant User as End user
    participant Advertiser
    participant Encore
    participant You as Your server

    User->>Advertiser: converts at the advertiser
    Advertiser->>Encore: conversion postback
    Encore->>Encore: verify the transaction
    Encore->>You: POST your webhook URL
    Note over Encore,You: X-Webhook-Signature + X-Webhook-Timestamp
    You->>You: verify signature, then 200 immediately
    You-->>Encore: 200 OK
    You->>You: grant the reward asynchronously
```

1. **A completion is verified.** The event fires at the moment Encore marks a transaction **verified** — driven by the advertiser's side (a conversion postback, or the advertiser's own completion call) — **not** when the user taps or claims the offer. A claim that never converts produces no webhook. (One exception exists for development: on the demo campaign, a test-conversion call from your own integration simulates the postback, so you can exercise the flow end to end without a live advertiser.)
2. **Encore signs and POSTs once.** We send a JSON body plus a hex HMAC-SHA256 signature over `timestamp.body`, keyed by your app's signing secret.
3. **You verify and acknowledge fast.** Check the signature, return `2xx`, and do your real work off the request path.

***

## Setting it up

Configuration lives in the [dashboard](https://dashboard.encorekit.com) under **Settings** for the app you want events for. It's dashboard-only — there's no publishable-key API for managing webhook config — and everything below is a control on that screen.

<Steps>
  <Step title="Enter your webhook URL">
    Paste the HTTPS URL of the endpoint you want called (for example `https://your-server.com/webhooks/encore`) and save. **One webhook per app** — to point somewhere else, edit the existing URL rather than adding a second one.
  </Step>

  <Step title="Copy your signing secret">
    Creating the webhook generates a signing secret of the form `whsec_…`. Copy it and store it as a secret on your server (an environment variable — never in client code or a repo). You need it to verify every delivery.
  </Step>

  <Step title="Send a test delivery">
    While the webhook is enabled, use **Send Test** to POST a sample `offer_completed` event to your URL right now. The dashboard reports back the HTTP status your server returned and how long it took, so you can confirm the endpoint is reachable and your signature check passes before any real traffic arrives. The sample uses realistic placeholder values (see [Test deliveries](#test-deliveries)).
  </Step>

  <Step title="Enable or pause">
    The status toggle turns delivery on and off. While paused, no events are sent — and because there are no retries, **events that occur while paused are lost, not queued**.
  </Step>
</Steps>

You can also **rotate the signing secret** (invalidates the old one immediately — update your server first, or you'll reject deliveries in the gap) and **delete the webhook** entirely.

### URL requirements

| Requirement                  | Why                                                                                                                                                                                                                               |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **HTTPS**                    | Plain `http://` URLs are rejected. Payloads carry user identifiers.                                                                                                                                                               |
| **Publicly resolvable host** | An SSRF guard resolves the hostname before every delivery and refuses `localhost` plus private, loopback, and link-local addresses — including a public hostname that *resolves* to one. See [Testing locally](#testing-locally). |
| **No redirects**             | Deliveries are sent with redirects disabled. A `301`/`302` on your endpoint fails the delivery outright — publish the final URL.                                                                                                  |

***

## The request

```http theme={null}
POST /webhooks/encore HTTP/1.1
Content-Type: application/json
X-Webhook-Signature: 5a4f2e8c1b3d9e7f6a0c8b2d4e6f1a3c5b7d9e0f...
X-Webhook-Timestamp: 1772044200
```

`1772044200` is the unix-seconds form of the `2026-02-25T18:30:00.000Z` in the sample body below — the two are generated moments apart on a real delivery, so they will agree to within a second or so. The header value is the one that goes into the signature.

### Headers

| Header                | Value                                                                                                                                                   |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Content-Type`        | Always `application/json`.                                                                                                                              |
| `X-Webhook-Signature` | Hex-encoded HMAC-SHA256 over the [signature base string](#verifying-the-signature), keyed by your app's signing secret.                                 |
| `X-Webhook-Timestamp` | Unix time in **seconds** at the moment we signed. This is the value that goes into the signature base string, and the one to use for a freshness check. |

### Body

```json theme={null}
{
  "event": "offer_completed",
  "timestamp": "2026-02-25T18:30:00.000Z",
  "transactionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "userId": "usr_9f8e7d6c-5b4a-3210-fedc-ba0987654321",
  "campaignName": "Audible Premium Plus",
  "payout": 10
}
```

<Note>
  **The `usr_…` shape above is only a placeholder**, taken from the sample Encore sends in a test delivery. Encore does **not** mint user ids and adds no prefix. In production, `userId` is byte-for-byte the value your app supplied — an email, a database primary key, a UUID, whatever you happen to use.
</Note>

| Field           | Type             | Notes                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| --------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `event`         | string           | Always `"offer_completed"`. It is currently the only event type Encore sends. Still switch on it, so a future event type doesn't fall through your handler.                                                                                                                                                                                                                                                                                                       |
| `timestamp`     | ISO 8601 string  | UTC, milliseconds, trailing `Z` — when Encore generated **this delivery**. Note this is a *different format* from the `X-Webhook-Timestamp` header (unix seconds); the header is the one you sign and check for freshness.                                                                                                                                                                                                                                        |
| `transactionId` | string (UUID)    | The Encore transaction that completed. **Treat this as your idempotency key** — see [Handle duplicates idempotently](#handle-duplicates-idempotently).                                                                                                                                                                                                                                                                                                            |
| `userId`        | string           | The end-user identifier **as your app supplied it, verbatim** — the value you passed to `identify()` or as `userId` on an Offers API call. Encore stores it opaquely and echoes it back unchanged, with no prefix or transformation, so it's the key you look the user up by on your side. (If you never called `identify()`, this is the SDK's anonymous per-device id, which won't resolve to anything in your database — another reason to call `identify()`.) |
| `campaignName`  | string           | Human-readable name of the campaign the user completed (e.g. `"Audible Premium Plus"`). Display/logging value — not a stable identifier; campaign names can change.                                                                                                                                                                                                                                                                                               |
| `payout`        | number \| `null` | The payout amount associated with this completion, when one was reported. **`null` is normal and common** — several completion paths carry no amount at delivery time, so code for `null` rather than treating it as an error. No currency field is delivered. Treat your dashboard analytics, not this field, as the source of truth for revenue reporting.                                                                                                      |

<Note>
  **That's the whole body.** There is no app id, no campaign id, no creative id, no event id, and no nesting — the payload is flat, exactly the six fields above. If you have a handler written against a nested `req.body.data`, it won't work: read the fields off the top level.
</Note>

***

## Verifying the signature

Never trust an unverified delivery. Anyone can POST JSON at a public URL, so the signature is what proves the request came from Encore.

The signature base string is the timestamp and the **raw request body**, joined by a single dot:

```
<X-Webhook-Timestamp>.<raw_body>
```

HMAC-SHA256 that string with your `whsec_…` signing secret and hex-encode it. The result must equal `X-Webhook-Signature`.

<Warning>
  Sign the **exact bytes we sent**, not a re-serialization. Body parsers and middleware can normalize whitespace, escaping, or encoding in ways that are invisible once the payload is a parsed object but fatal to a byte-exact HMAC — and the guarantee varies by language and parser. Capture the raw body — in Express, `express.raw()` or the `verify` hook of `express.json()`.
</Warning>

```javascript theme={null}
import express from 'express';
import crypto from 'node:crypto';

const app = express();
const MAX_SKEW_SECONDS = 300; // 5 minutes

// Fail fast at boot rather than inside createHmac on the first delivery.
const SIGNING_SECRET = process.env.ENCORE_WEBHOOK_SECRET; // whsec_...
if (!SIGNING_SECRET) {
  throw new Error(
    'ENCORE_WEBHOOK_SECRET is not set — copy the signing secret from the dashboard under Settings.',
  );
}

// Mount the RAW body parser on this route — the signature covers exact bytes.
app.post(
  '/webhooks/encore',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.get('X-Webhook-Signature');
    const timestamp = req.get('X-Webhook-Timestamp');
    const rawBody = req.body.toString('utf8');

    if (!signature || !timestamp) {
      return res.status(400).send('Missing signature headers');
    }

    // 1. Replay protection: reject stale (or future-dated) timestamps.
    const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
    if (!Number.isFinite(age) || age > MAX_SKEW_SECONDS) {
      return res.status(401).send('Timestamp outside tolerance');
    }

    // 2. Recompute the HMAC over `${timestamp}.${rawBody}`.
    const expected = crypto
      .createHmac('sha256', SIGNING_SECRET)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex');

    // 3. Constant-time compare — never `===`, which leaks timing information.
    const a = Buffer.from(signature, 'utf8');
    const b = Buffer.from(expected, 'utf8');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(401).send('Invalid signature');
    }

    // 4. Verified. Acknowledge FIRST, then work asynchronously — there are no retries,
    //    so a slow handler risks the 5s timeout even though we already have the event.
    res.status(200).send('OK');

    const payload = JSON.parse(rawBody);
    if (payload.event === 'offer_completed') {
      // Idempotent on transactionId — see "Handle duplicates idempotently".
      void grantReward({
        transactionId: payload.transactionId,
        userId: payload.userId,
        campaignName: payload.campaignName,
        payout: payload.payout, // may be null
      });
    }
  },
);

app.listen(3000);
```

<Tip>
  Three details are easy to get wrong and all three are security- or correctness-relevant: use the **raw body**, use **`crypto.timingSafeEqual`** rather than `===`, and enforce a **timestamp freshness window** so a captured delivery can't be replayed at you later.
</Tip>

***

## Delivery guarantees

Be deliberate here — the guarantees are intentionally thin, and designing around them is your job, not ours.

| Behaviour                       | What actually happens                                                                            |
| ------------------------------- | ------------------------------------------------------------------------------------------------ |
| **Retries**                     | **None.** A delivery is attempted exactly once.                                                  |
| **Timeout**                     | 5 seconds. A slower response is abandoned and the event is dropped.                              |
| **Non-2xx response**            | Logged on Encore's side and dropped. Not retried, not queued, not replayable from the dashboard. |
| **Redirects**                   | Rejected. The delivery fails.                                                                    |
| **Webhook disabled or deleted** | Nothing is sent, and nothing is backfilled when you re-enable it.                                |
| **Ordering**                    | Not guaranteed.                                                                                  |
| **Event id**                    | None delivered. Use `transactionId` for deduplication.                                           |

Consequences worth designing for:

### Return `2xx` fast, work asynchronously

Acknowledge as soon as the signature checks out, then hand the work to a queue or a background task. Doing database writes, third-party calls, or email sends *before* responding is the most common way publishers hit the 5-second timeout and lose events they had already received.

### Handle duplicates idempotently

There are no retries, so duplicates are rare — but "rare" is not "never", and an idempotent handler costs almost nothing. Key on `transactionId`: record the ids you've processed and make a second delivery for the same id a no-op. Without that, a repeat can double-grant a reward.

### Reconcile anything financial

Because a dropped delivery is unrecoverable, **never treat this webhook as your ledger.** If money, credits, or entitlements depend on completions, reconcile periodically against your Encore dashboard analytics and treat the webhook as a low-latency *hint* that a completion happened, not as the record that it did.

***

## Test deliveries

**Send Test** in the dashboard posts a sample event to your live URL with a real signature computed from your current signing secret — so it exercises your verification path end to end, not just your routing. The dashboard reports the status code your server returned and the round-trip duration.

The sample payload is well-formed and realistic, but its `transactionId` and `userId` do **not** correspond to a real transaction or a real user in your system. Make sure your handler tolerates an unknown user gracefully instead of throwing (which would return a non-2xx and make a working endpoint look broken).

***

## Testing locally

The SSRF guard resolves your hostname before every delivery and refuses private addresses, so `http://localhost:3000` and `https://192.168.x.x` can never be webhook URLs — including for test deliveries.

To develop against a local server, expose it through a tunnel that gives you a public HTTPS hostname (ngrok, Cloudflare Tunnel, Tailscale Funnel, or similar), point the webhook at the tunnel URL, and use **Send Test**. Swap in your production URL when you go live.

***

## Troubleshooting

<AccordionGroup>
  <Accordion title="Signature never matches">
    Almost always a body-bytes problem. Verify against the **raw** request body, not `JSON.stringify(req.body)` — re-serializing is not guaranteed to reproduce the bytes we signed. Also confirm you're joining timestamp and body with a literal dot, hex-encoding (not base64), using the header timestamp rather than your own clock, and using the current secret if you recently rotated it.
  </Accordion>

  <Accordion title="Send Test reports a timeout">
    Your endpoint took longer than 5 seconds. Return `2xx` as soon as the signature verifies and move the real work off the request path.
  </Accordion>

  <Accordion title="Send Test fails immediately with a connection error">
    Check that the URL is HTTPS, that the host is publicly resolvable (not `localhost`, not a private IP — use a tunnel), and that the path doesn't redirect. Redirects are rejected rather than followed.
  </Accordion>

  <Accordion title="No events arriving in production">
    Confirm the webhook's status toggle is enabled, and remember the event fires when a completion is **verified** — normally driven by the advertiser, not by the user claiming the offer. A claim with no verified conversion produces no webhook. Run **Send Test** first to isolate reachability from event volume.
  </Accordion>

  <Accordion title="A completion appears in the dashboard but no webhook arrived">
    Expected under the no-retry model: the delivery was attempted and failed (endpoint down, non-2xx, timeout, TLS error) and was dropped. Deliveries are not replayable. Reconcile from dashboard analytics.
  </Accordion>
</AccordionGroup>

***

## Next steps

* **[Offers API — Feed](/publishers/offers-api/feed)**: fetch and render offers in your own UI.
* **[Creator Affiliate Links](/publishers/offers-api/creator-links)**: per-creator trackable links, with a stats endpoint for attribution you can poll.
* **[Webhook Ingestion](/publishers/web/sdk-reference/webhook-ingestion)**: the *inbound* direction — forwarding your own subscription events into Encore for incrementality measurement.
