# Guide to Rewardful Webhooks: Features and Best Practices

Rewardful adds affiliate and referral programmes to SaaS products that bill through Stripe. It tracks which affiliate referred a visitor, links the resulting Stripe customer and sales to that referral, and calculates commissions and payouts. Webhooks let the rest of your stack follow along: welcome a newly confirmed affiliate, flag a converted referral in your CRM, or reconcile commission payouts in your accounting tool.

This guide covers Rewardful's 31 event types, the event and request IDs in each body, verifying `X-Rewardful-Signature`, and how strict Rewardful is about the response code.

## What are Rewardful webhooks?

You add endpoints on the **Webhooks** page of the Rewardful dashboard and choose which event types each one receives. Every endpoint gets its own Signing Secret.

Deliveries are JSON `POST` requests with three parts: `object`, the record that changed, shaped like the matching REST API object; `event`, with the event's `id`, `type`, `created_at` and `api_version`; and `request`, whose `id` identifies this particular delivery attempt.

Rewardful signs the raw body with HMAC-SHA256 using the endpoint's secret and sends the hex digest in `X-Rewardful-Signature`.

## Rewardful webhook features

| Feature | Details |
| --- | --- |
| Configuration | Dashboard **Webhooks** page, per endpoint |
| Signature | hex HMAC-SHA256 |
| Signature header | `x-rewardful-signature` |
| Signed content | The raw request body |
| Key | One Signing Secret per endpoint |
| Body | JSON: `object`, `event`, `request` |
| Event routing | Body field `event.type` |
| IDs | `event.id` per event, `request.id` per attempt |
| Success | HTTP `200` only |
| Retries | Exponential backoff for 3 days, then dropped |

## Events and payloads

| Group | Event types | Common use |
| --- | --- | --- |
| Affiliates | `affiliate.created`, `affiliate.confirmed`, `affiliate.updated`, `affiliate.deleted` | Onboard affiliates, sync to a CRM |
| Links | `affiliate_link.created`, `affiliate_link.updated`, `affiliate_link.deleted` | Keep share links current |
| Coupons | `affiliate_coupon.created`, `affiliate_coupon.updated`, `affiliate_coupon.activated`, `affiliate_coupon.deactivated`, `affiliate_coupon.deleted` | Sync promo codes to checkout |
| Referrals | `referral.created`, `referral.lead`, `referral.converted`, `referral.deleted` | Track funnel stages; `converted` means a paying customer |
| Sales | `sale.created`, `sale.updated`, `sale.refunded`, `sale.deleted` | Revenue attribution |
| Commissions | `commission.created`, `commission.updated`, `commission.paid`, `commission.voided`, `commission.deleted` | Affiliate earnings |
| Payouts | `payout.created`, `payout.updated`, `payout.due`, `payout.paid`, `payout.deleted`, `payout.failed` | Payout operations and accounting |

`affiliate.created` fires both when someone signs up and when you create an affiliate in the dashboard or API; `affiliate.confirmed` follows when they confirm their email. `affiliate_coupon.activated` means a coupon was re-activated after being deactivated.

### Published payload example

Rewardful publishes one full example, `affiliate.confirmed`, available in the [webhook samples library](https://github.com/hookdeck/webhook-samples/blob/main/providers/rewardful/v1/affiliate.confirmed.json). Other events follow the same envelope with a different `object`.

```json
{
  "object": {
    "id": "3433fffa-8255-4843-81b1-22aebc21eb34",
    "first_name": "James",
    "last_name": "Bond",
    "email": "james@mi6.co.uk",
    "created_at": "2019-06-24T00:43:36.097Z",
    "updated_at": "2019-06-27T18:38:41.684Z",
    "referrals": 42,
    "conversions": 3,
    "confirmed_at": "2019-06-24T00:43:36.095Z",
    "links": [
      {
        "id": "81bf1128-0ba1-4c1c-835c-286521f8791a",
        "url": "http://www.demo.com:8080/?via=james",
        "token": "james",
        "referrals": 42,
        "conversions": 3
      }
    ],
    "campaign": {
      "id": "82f26208-778e-4e98-a467-6ee264cf96d5",
      "name": "Friends of Acme",
      "created_at": "2019-06-24T00:43:35.985Z",
      "updated_at": "2019-06-24T00:43:35.985Z"
    },
    "paypal_email": null,
    "sign_in_count": 0,
    "stripe_account_id": null,
    "unconfirmed_email": null,
    "stripe_customer_id": null,
    "paypal_email_confirmed_at": null,
    "receive_new_commission_notifications": true
  },
  "event": {
    "id": "7fcdc81d-996a-4b7b-8faf-1db98b9d5507",
    "type": "affiliate.confirmed",
    "created_at": "2019-07-11T05:25:43.940Z",
    "api_version": "v1"
  },
  "request": {
    "id": "66a94f49-2b36-403c-83b8-91677cb1f460"
  }
}

```

## Setting up Rewardful webhooks

### Prerequisites

* A Rewardful account with access to the Webhooks page of the dashboard
* A publicly reachable HTTPS endpoint

### Register Your Endpoint

1. Open the **Webhooks** page in your Rewardful dashboard.
2. Add an endpoint with your URL, for example `https://example.com/webhooks/rewardful`.
3. Select its event types, such as `affiliate.confirmed`, `referral.converted` and `commission.created`.
4. Save.

### Get Your Signing Secret

The endpoint's **Signing Secret** is shown on the same page. Store it as:

```bash
WEBHOOK_SECRET=your_endpoint_signing_secret

```

Use its UTF-8 bytes as the HMAC key, without decoding. If you have several endpoints, each needs its own secret.

### Test a delivery

Start a local receiver and forward requests with the [Hookdeck CLI](/docs/cli):

```bash
npx hookdeck-cli listen 3000 rewardful --path /webhooks/rewardful

```

Add the CLI's URL as an endpoint, then create and confirm a test affiliate to trigger `affiliate.created` and `affiliate.confirmed`.

## Securing Rewardful webhooks

Compute HMAC-SHA256 of the raw body with the Signing Secret, hex-encode it, and compare it with `X-Rewardful-Signature` in constant time. Rewardful's Rails, PHP and Django samples read the raw body correctly but compare with `==` or `!==`. See [Signed webhooks](https://developers.rewardful.com/webhooks/signed-webhooks).

```javascript
const crypto = require('node:crypto');

function verifySignature(rawBody, headers, secret) {
  if (!Buffer.isBuffer(rawBody) || typeof secret !== 'string' || !secret) return false;
  const signature = headers['x-rewardful-signature'];
  if (typeof signature !== 'string') return false;
  const key = Buffer.from(secret, 'utf8');
  const message = rawBody;
  const candidates = [signature];
  const expected = crypto.createHmac('sha256', key).update(message).digest();
  return candidates.some((candidate) => {
    if (!/^[0-9a-fA-F]{64}$/.test(candidate)) return false;
    const received = Buffer.from(candidate, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

```

The handler deduplicates on `event.id` (not `request.id`, which changes between attempts) and returns exactly `200`.

```javascript
const express = require('express');

function createRewardfulApp({ firstSeen, crm, finance }) {
  const app = express();
  app.post('/webhooks/rewardful', express.raw({ type: '*/*' }), async (req, res) => {
    const secret = process.env.WEBHOOK_SECRET;
    if (!secret) return res.sendStatus(500);
    if (!verifySignature(req.body, req.headers, secret)) return res.sendStatus(401);
    let parsed;
    try {
      parsed = JSON.parse(req.body.toString('utf8'));
    } catch {
      return res.sendStatus(400);
    }
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return res.sendStatus(400);
    const { event, object } = parsed;
    if (!event || typeof event !== 'object' || !event.id || typeof event.type !== 'string') {
      return res.sendStatus(400);
    }
    try {
      if (!(await firstSeen(event.id))) return res.sendStatus(200);
      const [resource] = event.type.split('.');
      if (resource === 'affiliate' || resource === 'referral') {
        await crm.sync(event.type, object);
      } else if (resource === 'commission' || resource === 'payout' || resource === 'sale') {
        await finance.record(event.type, object, event.created_at);
      } else {
        console.log('Unhandled Rewardful event', event.type);
      }
    } catch {
      return res.sendStatus(503);
    }
    return res.sendStatus(200);
  });
  return app;
}

```

There's no signed timestamp, so a captured request stays valid. `event.id` is what stops a replay or retry from recording a sale or commission twice.

On a Rewardful source, Hookdeck checks `X-Rewardful-Signature` as a hex HMAC-SHA256 of the raw body with your Signing Secret. It answers Rewardful with `200` either way, logs a mismatch as failed verification and doesn't forward that request. No timestamp tolerance is involved.

> **Make Rewardful webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies every Rewardful event, sends affiliate updates to your CRM and commissions to finance, and keeps them replayable beyond Rewardful's 3-day retry window.

## Rewardful webhook limitations and pain points

### Only `200` counts

**The Problem:** Events are retried for days even though your endpoint accepted them.

**Why It Happens:** Rewardful treats any status other than `200` as a failure, including `201`, `202` and `204`.

**Workarounds:**

* Return `200` explicitly from the webhook route.

**How Hookdeck Can Help:** Hookdeck answers Rewardful with `200` itself, whatever status your service returns.

### A three-day limit

**The Problem:** Events from a long outage or a broken deploy are lost for good.

**Why It Happens:** Rewardful retries with exponential backoff for 3 days, then drops the event.

**Workarounds:**

* Reconcile affiliates, referrals and commissions through the Rewardful API after incidents.

**How Hookdeck Can Help:** Hookdeck stores each event on the first attempt, so it can be replayed whenever your service is ready.

### One example payload

**The Problem:** Code for sales, commissions and payouts has to be written without example bodies.

**Why It Happens:** Only `affiliate.confirmed` has a documented example; the docs say other objects match the REST API.

**Workarounds:**

* Use the REST API reference for each object, and capture real events in a test account.

**How Hookdeck Can Help:** Hookdeck keeps the full body of every event it receives, so real payloads for each type are on hand.

### Per-endpoint secrets

**The Problem:** Deliveries to one URL fail verification after adding a second endpoint.

**Why It Happens:** Every endpoint has its own Signing Secret, and one endpoint's secret doesn't verify another's deliveries.

**Workarounds:**

* Give each endpoint its own path or service.

**How Hookdeck Can Help:** One Hookdeck source per endpoint keeps each secret paired with its traffic.

## Best practices

### Deduplicate on `event.id`

`request.id` identifies the attempt and changes on retry. `event.id` stays the same, so use it as your idempotency key, as our [idempotency guide](/webhooks/guides/implement-webhook-idempotency) recommends.

### Treat `referral.converted` as the conversion

It marks the move to a paying customer. Earlier stages (`created`, `lead`) are useful for funnel analytics but don't represent revenue.

### Handle refunds and voids

`sale.refunded` and `commission.voided` reverse earlier events. Make sure your accounting applies them against the original records.

### Acknowledge, then process

Return `200` after storing the event and do CRM and accounting updates in the background, as described in the [asynchronous processing guide](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Rewardful's webhooks follow a clear model: an event block with a stable ID, the changed object, and a hex HMAC over the body. Return exactly `200`, deduplicate on `event.id` rather than `request.id`, and plan for the 3-day retry limit and the lack of example bodies beyond `affiliate.confirmed`.

[Hookdeck Event Gateway](https://hookdeck.com) handles the response code for you and keeps every affiliate, sale and payout event replayable.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Rewardful webhooks reliably in minutes.