# Guide to Tremendous Webhooks: Features and Best Practices

Tremendous webhooks tell your systems when rewards are delivered or fail, when orders are approved or rejected, and when funds land in your balance. If you send gift cards, payouts or research incentives through Tremendous, webhooks are how those outcomes reach your own records without polling the API.

This guide covers how the Tremendous webhook endpoint is configured and signed, the events and their payload, how retries work, and the practices that keep a Tremendous receiver reliable.

## What are Tremendous webhooks?

Each Tremendous organization can have one webhook endpoint, and you configure it through the API. That endpoint hears about every event in the account: you can't choose a subset, so your receiver filters on the event name. When something happens, Tremendous sends an HTTPS `POST` with a JSON body.

The body is a notification. It names the event and the resource it concerns, by type and ID, and for most events nothing more. You fetch the resource from the API when you need its details.

Every request is signed. The `Tremendous-Webhook-Signature` header holds `sha256=` followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook's `private_key`.

## Tremendous webhook features

| Feature | Details |
| --- | --- |
| Configuration | API only: `POST /webhooks`, plus list, retrieve and delete |
| Endpoints | One per organization. Posting again with a new `url` changes it |
| Subscription | Every event in the account. No pre-selection |
| Environments | Production (`api.tremendous.com`) and sandbox (`testflight.tremendous.com`), each with its own webhook |
| Test events | `POST /webhooks/{id}/simulate` with an event name |
| Signature header | `Tremendous-Webhook-Signature` |
| Scheme | HMAC-SHA256, hex, always prefixed with `sha256=` |
| Signed content | The raw request body only |
| Secret | The webhook's `private_key`, returned by the create, list and retrieve calls |
| Success response | `200` |
| Retries | Up to 17 times, with exponential backoff |
| Idempotency key | `uuid` in the body |
| Source IPs | Production: `34.150.143.187`, `34.86.45.235`, `35.188.236.188`, `34.150.208.194`. Sandbox: `35.194.78.88`, `34.150.132.8`, `34.145.182.28`, `35.221.12.21` |

## Common events

Tremendous documents 36 event types. Names are upper-case and dotted.

| Area | Events |
| --- | --- |
| Rewards | `REWARDS.DELIVERY.SUCCEEDED`, `REWARDS.DELIVERY.FAILED`, `REWARDS.CANCELED`, `REWARDS.FLAGGED` |
| Fraud review | `FRAUD_REVIEWS.RELEASED`, `FRAUD_REVIEWS.BLOCKED` |
| Orders | `ORDERS.CREATED`, `ORDERS.APPROVED`, `ORDERS.CANCELED`, `ORDERS.FAILED` |
| Funding | `FUNDING_SOURCES.CREATED`, `FUNDING_SOURCES.DELETED`, `FUNDING_SOURCES.FUNDED` |
| Top-ups | `TOPUPS.CREATED`, `TOPUPS.FULLY_CREDITED`, `TOPUPS.PARTIALLY_CREDITED`, `TOPUPS.REVERSED`, `TOPUPS.REJECTED` |
| Invoices | `INVOICES.CREATED`, `INVOICES.DELETED`, `INVOICES.PAID` |
| Campaigns and team | `CAMPAIGNS.CREATED`, `CAMPAIGNS.DELETED`, `MEMBERS.CREATED`, `MEMBERS.DELETED` |
| Catalog and reports | `PRODUCTS.ADDED`, `PRODUCTS.UPDATED`, `PRODUCTS.REMOVED`, `REPORTS.GENERATION.SUCCEEDED`, `REPORTS.GENERATION.FAILED` |
| Connected organizations | `CONNECTED_ORGANIZATIONS.REGISTERED`, `CONNECTED_ORGANIZATIONS.STATUS.APPROVED`, `CONNECTED_ORGANIZATIONS.STATUS.REJECTED`, `CONNECTED_ORGANIZATIONS.OAUTH.GRANTED`, `CONNECTED_ORGANIZATIONS.DELETED`, `CONNECTED_ORGANIZATION_MEMBERS.DELETED` |

`ORDERS.APPROVED` fires only when order approvals are turned on. A `GET /webhooks/{id}/events` call lists the event types your endpoint can receive.

Here is Tremendous's example request body:

```json
{
  "event": "CAMPAIGNS.CREATED",
  "uuid": "5ccc7bb1-7659-4e23-a407-77d8cd9c62f5",
  "created_utc": "2021-04-06T20:05:01.037-04:00",
  "payload": {
    "resource": {
      "id": "2V3PCCL7QXDA",
      "type": "campaigns"
    },
    "meta": {}
  }
}

```

* `event` is the event name. Route on it.
* `uuid` is the webhook's unique ID. Tremendous asks you to check it so you don't act on the same webhook twice.
* `payload.resource` names the resource by `type` (such as `rewards`, `orders` or `funding_sources`) and `id`.
* `payload.meta` is `{}` for most events. For `ORDERS.CREATED` it holds the created order, and for `REWARDS.DELIVERY.FAILED` it holds the failure details.

## Setting up Tremendous webhooks

There is no dashboard form for webhooks. Create the endpoint with your API key, starting in the sandbox:

```bash
curl -X POST https://testflight.tremendous.com/api/v2/webhooks \
  -H "Authorization: Bearer $TREMENDOUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/webhooks/tremendous" }'

```

The response includes the webhook's `id` and `private_key`. Store the key as `TREMENDOUS_WEBHOOK_PRIVATE_KEY`. Unlike many providers, Tremendous returns it again from the list and retrieve calls, so you can recover it later.

To check your receiver, ask Tremendous to fire a test event:

```bash
curl -X POST https://testflight.tremendous.com/api/v2/webhooks/$WEBHOOK_ID/simulate \
  -H "Authorization: Bearer $TREMENDOUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event": "INVOICES.PAID" }'

```

Repeat the setup against `https://api.tremendous.com/api/v2` for production. The two environments have separate webhooks, separate keys and separate source IPs.

If you use Hookdeck, create a source with the Tremendous source type, paste the `private_key` into it, and use the Hookdeck source URL as the webhook `url`.

## Securing Tremendous webhooks

Read `Tremendous-Webhook-Signature`, check that it starts with `sha256=`, and take the rest. Compute the hex HMAC-SHA256 of the raw request body with the `private_key` as the key, and compare the two in constant time.

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

function verifyTremendousSignature(rawBody, signatureHeader, privateKey) {
  if (!signatureHeader || !privateKey || !signatureHeader.startsWith('sha256=')) return false;
  const received = Buffer.from(signatureHeader.slice('sha256='.length).toLowerCase(), 'utf8');
  const expected = Buffer.from(
    crypto.createHmac('sha256', privateKey).update(rawBody).digest('hex'),
    'utf8'
  );
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/tremendous', express.raw({ type: '*/*' }), (req, res) => {
  const valid = verifyTremendousSignature(
    req.body,
    req.get('tremendous-webhook-signature'),
    process.env.TREMENDOUS_WEBHOOK_PRIVATE_KEY
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.uuid, route on event.event, and return 200 for events you ignore.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac

def verify_tremendous_signature(raw_body: bytes, signature_header, private_key: str) -> bool:
    if not signature_header or not private_key or not signature_header.startswith("sha256="):
        return False
    received = signature_header[len("sha256="):].lower()
    expected = hmac.new(private_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_tremendous_signature(raw_body, request.headers.get("tremendous-webhook-signature"),
#                             os.environ["TREMENDOUS_WEBHOOK_PRIVATE_KEY"])

```

Tremendous's Ruby example compares with `==`. A constant-time comparison, as above, is the better choice. The signature covers only the body and there is no timestamp, so there is no replay window to check. Tremendous's fixed source IPs give you an extra network-level check if you want one.

> **Make Tremendous webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `Tremendous-Webhook-Signature` header on every delivery, answers with a `200` straight away, and routes reward, order and funding events to the services that need them.

## Tremendous webhook limitations and pain points

### One endpoint for everything

**The Problem:** Several services need different Tremendous events, but the organization can only register one URL.

**Why It Happens:** Tremendous allows a single webhook per organization, and it receives every event. Tremendous suggests building your own receiver that fans events out to the rest of your infrastructure.

**Workarounds:**

* Run one receiver that verifies, stores and then dispatches events by name to internal queues.
* Return `200` quickly for events nobody needs.

**How Hookdeck Can Help:** Point the one Tremendous webhook at Hookdeck and add a connection per service, each with a [filter](/docs/filters) on `event`. Hookdeck does the fan-out without a receiver of your own.

### Only `200` counts as success

**The Problem:** Tremendous retries events your service already handled.

**Why It Happens:** Tremendous asks for a `200` specifically. A `201`, `202` or `204` from your framework may be treated as a failure, and the same webhook is sent again, up to 17 times.

**Workarounds:**

* Return exactly `200` from the webhook route.
* Deduplicate on `uuid` regardless, since retries also follow timeouts.

**How Hookdeck Can Help:** Hookdeck answers Tremendous with a `200` as soon as it has the request, whatever your service returns, and its [deduplication](/docs/deduplication) can drop repeated `uuid` values.

### Thin payloads

**The Problem:** A `REWARDS.DELIVERY.SUCCEEDED` event doesn't tell you which recipient or amount it was.

**Why It Happens:** For most events, the body holds only the resource type and ID. `meta` is populated only for `ORDERS.CREATED` and `REWARDS.DELIVERY.FAILED`.

**Workarounds:**

* Fetch the reward or order from the API in a background job, using `payload.resource.id`.
* Store the IDs Tremendous returned when you created the order, so you can match the event to your own records without a fetch.

**How Hookdeck Can Help:** Hookdeck keeps each request body, so you can check exactly what `meta` contained for a failed delivery when you investigate it later.

### No replay protection

**The Problem:** A captured request still verifies if someone resends it.

**Why It Happens:** Tremendous signs only the body, without a timestamp.

**Workarounds:**

* Deduplicate on `uuid`.
* Allow only Tremendous's published IPs for the environment you are in.

**How Hookdeck Can Help:** Hookdeck's deduplication can reject a `uuid` it has already seen before the request reaches your service.

## Best practices

### Verify against the raw body

Tremendous warns that any change to the body, such as calling `JSON.stringify` on parsed JSON, produces a different signature. Use `express.raw({ type: '*/*' })` in Express, `await request.text()` in Next.js and `await request.body()` in FastAPI.

### Test in the sandbox with simulate

Use `POST /webhooks/{id}/simulate` in the sandbox to send each event your code handles, including `REWARDS.DELIVERY.FAILED`, before you go live.

### Keep sandbox and production apart

The environments have different keys and IPs. Use separate secrets, and if you allowlist IPs, use the list for the right environment.

### Acknowledge fast and process asynchronously

Tremendous recommends processing webhooks out of the request cycle, for example in a worker queue, so it gets a timely response and doesn't retry. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks) and our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

## Conclusion

Tremendous sends every event in an organization to one endpoint and signs each request with `Tremendous-Webhook-Signature`, a `sha256=`-prefixed hex HMAC-SHA256 of the raw body keyed with the webhook's `private_key`. Verify against the raw bytes, return exactly `200`, deduplicate on `uuid`, and fetch details from the API when `meta` is empty.

[Hookdeck Event Gateway](https://hookdeck.com) verifies every Tremendous signature, acknowledges with a `200`, and fans each event out to the services that need it, which a single-endpoint provider otherwise leaves to you.

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