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

# Receive Completion Events

> Wire your server to react when a user's offer completion is verified, via the offer_completed webhook.

When one of your users completes an offer and the completion is **verified**, Encore can `POST` an `offer_completed` event to a URL you own. Use it when your server needs to *react* to a completion: grant a reward, credit a balance, kick off an email. You host one HTTPS endpoint; nothing to poll.

This is the final stage of [the shared flow](/publishers/offers-api/overview#the-flow), and it fires on **verification** (the advertiser's conversion postback), not on the user tapping or claiming; a claim that never converts produces no webhook. The payload contract (headers, body fields, signature spec, delivery guarantees, URL requirements) is the [offer\_completed webhook reference](/publishers/offers-api/reference/offer-completed-webhook); this guide is the wiring.

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

## Set it up

Configuration lives in the [dashboard](https://dashboard.encorekit.com) under **Settings** for the app you want events for; there is no publishable-key API for webhook config.

<Steps>
  <Step title="Enter your webhook URL">
    Paste the HTTPS URL of your endpoint (for example `https://your-server.com/webhooks/encore`) and save. **One webhook per app**: to point somewhere else, edit the existing URL. It must be HTTPS, publicly resolvable, and redirect-free ([URL requirements](/publishers/offers-api/reference/offer-completed-webhook#url-requirements)).
  </Step>

  <Step title="Copy your signing secret">
    Creating the webhook generates a `whsec_...` signing secret. Store it as a server-side secret (an environment variable, never in client code). Rotating it invalidates the old one immediately: update your server first, or you'll reject deliveries in the gap.
  </Step>

  <Step title="Send a test delivery">
    **Send Test** POSTs a sample event to your live URL with a real signature from your current secret, exercising your verification path end to end. The sample's `transactionId` and `userId` are not real records, so make sure your handler tolerates an unknown user gracefully instead of throwing.
  </Step>

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

## Verify the signature

Never trust an unverified delivery; the signature proves the request came from Encore. Recompute HMAC-SHA256 over `<X-Webhook-Timestamp>.<raw_body>` with your `whsec_...` secret and compare hex digests ([signature spec](/publishers/offers-api/reference/offer-completed-webhook#signature)).

<Warning>
  Sign the **exact bytes Encore sent**, not a re-serialization. Body parsers can normalize whitespace or escaping in ways that are fatal to a byte-exact HMAC. 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 "Design for the guarantees").
      void grantReward({
        transactionId: payload.transactionId,
        userId: payload.userId,
        campaignName: payload.campaignName,
        payout: payload.payout, // may be null
      });
    }
  },
);

app.listen(3000);
```

Full header and body contract for the delivery this handler verifies: [`offer_completed` webhook reference](/publishers/offers-api/reference/offer-completed-webhook#the-request-encore-sends).

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

## Design for the guarantees

The guarantees are intentionally thin (delivered once, 5-second timeout, no retries, no ordering, no event id; [full table](/publishers/offers-api/reference/offer-completed-webhook#the-response-encore-expects)). Three consequences worth designing for:

1. **Return `2xx` fast, work asynchronously.** Acknowledge as soon as the signature checks out, then hand the work to a queue. Doing database writes or third-party calls *before* responding is the most common way publishers hit the timeout and lose events they had already received.
2. **Handle duplicates idempotently.** Key on `transactionId`: record processed ids and make a second delivery for the same id a no-op. Without that, a repeat can double-grant a reward.
3. **Reconcile anything financial.** A dropped delivery is unrecoverable, so **never treat this webhook as your ledger.** Reconcile periodically against your Encore dashboard analytics and treat the webhook as a low-latency *hint* that a completion happened.

## Testing locally

The SSRF guard refuses private addresses, so `http://localhost:3000` can never be a webhook URL, including for test deliveries. Expose your local server through a tunnel with a public HTTPS hostname (ngrok, Cloudflare Tunnel, Tailscale Funnel), point the webhook at the tunnel URL, and use **Send Test**. On the demo campaign, a test-conversion call from your own integration simulates the advertiser postback, so you can exercise the full flow without a live advertiser.

## Troubleshooting

<AccordionGroup>
  <Accordion title="Signature never matches">
    Almost always a body-bytes problem. Verify against the **raw** request body, not `JSON.stringify(req.body)`. 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 or connection error">
    A timeout means your endpoint took longer than 5 seconds: return `2xx` as soon as the signature verifies. A connection error means the URL isn't HTTPS, the host isn't publicly resolvable (use a tunnel), or the path redirects (redirects are rejected, not followed).
  </Accordion>

  <Accordion title="No events arriving in production">
    Confirm the status toggle is enabled, and remember the event fires on **verified** completions, driven by the advertiser, not by claims. 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, failed (endpoint down, non-2xx, timeout, TLS error), and was dropped. Deliveries are not replayable; reconcile from dashboard analytics.
  </Accordion>
</AccordionGroup>

## Related

* [offer\_completed webhook reference](/publishers/offers-api/reference/offer-completed-webhook): the payload contract, signature spec, and delivery guarantees.
* [Rewarded Actions guide](/publishers/offers-api/guides/rewarded-actions): the flagship flow whose completions this webhook reports.
