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

# Product Subscription Webhooks

Product subscriptions notify you when a product's offers change: prices, availability, and offers appearing or going away across merchants. Subscribe to any canonical product, then Channel3 pushes `offer.*` events to your webhook endpoints the moment a change is detected.

Use product subscriptions to power back-in-stock alerts, deal finders, "this offer is gone" UX, and price-drop notifications — without polling.

> [!NOTE]
>
> Product subscriptions supersede price-tracking subscriptions, which are
> deprecated for new integrations: the [Price Tracking
> API](https://docs.trychannel3.com/guides/price-tracking.md) sends a single `price.changed` event, while
> product subscriptions offer four event types narrowed by merchant domain or
> variant. Both deliver to the same webhook endpoints. [Price
> history](https://docs.trychannel3.com/api-reference/price-tracking/get-price-history.md) is not part of this
> deprecation — it has no product-subscriptions equivalent and remains
> supported.

## At a glance

| | |
| - | - |
| **Events** | Four types, chosen per subscription: `offer.price.changed`, `offer.availability.changed`, `offer.created`, `offer.deleted` — price, availability, and the full offer lifecycle. |
| **Authentication** | HMAC-SHA256 in the [Standard Webhooks](https://www.standardwebhooks.com/) format: signature in the `svix-signature` header, with a per-endpoint secret starting `whsec_` — not a raw-body `sha256=<hex>` digest. [Details](#verifying-signatures) |
| **User-Agent** | `Svix-Webhooks/<version>` on every request — allowlist the `Svix-Webhooks/` prefix or [Svix's published source IPs](https://docs.svix.com/receiving/source-ips). [Details](#user-agent-and-allowlisting) |
| **Endpoints** | Managed per account in the [dashboard](https://trychannel3.com/dashboard) webhook portal (Svix), independent of subscriptions. Multiple endpoints supported — e.g. one for QA, one for production. |
| **Cadence** | Each subscribed product is re-scraped on a daily priority schedule; events fire when a change is detected, not on a fixed schedule. |
| **Limits** | Up to 25 merchant domains per subscription; no cap on subscription count. |
| **Billing** | Free — no credits charged today. Legacy [price tracking](https://docs.trychannel3.com/guides/price-tracking.md) remains billed at 1 credit per product per day. |

## How it works

1. **Register an endpoint** in the [Channel3 dashboard](https://trychannel3.com/dashboard) (see [Managing endpoints](#managing-endpoints)). This is where you get your signing secret.
2. **Create a subscription** with [`POST /v1/product-subscriptions`](https://docs.trychannel3.com/api-reference/product-subscriptions/create-subscription), choosing the event types and an optional scope.
3. **Receive events** as HTTP POSTs at every endpoint that has the matching event types enabled. [Verify the signature](#verifying-signatures) and respond `2xx` within 15 seconds.

Creating a subscription also schedules priority daily refreshes of the product, so subscribed products stay fresh. Cancelling the last subscription for a product removes that schedule.

## Create and manage subscriptions

Four endpoints, with full request and response schemas in the API reference:

* **[Create a subscription](https://docs.trychannel3.com/api-reference/product-subscriptions/create-subscription.md)** — `POST /v1/product-subscriptions`. Creating is idempotent per scope: if an identical active subscription (same events, domains, and selection) already exists, it is returned instead of a duplicate being created. Product IDs from `/v1/lookup` are canonicalized automatically; while the lookup is still processing, the call returns `409` — retry with the same ID once it resolves.
* **[List subscriptions](https://docs.trychannel3.com/api-reference/product-subscriptions/list-subscriptions.md)** — filter by `status`, paginate with `cursor` and `limit`.
* **[Get a subscription](https://docs.trychannel3.com/api-reference/product-subscriptions/get-subscription.md)** — fetch one subscription by ID.
* **[Cancel a subscription](https://docs.trychannel3.com/api-reference/product-subscriptions/cancel-subscription.md)** — returns the subscription with `status: "cancelled"`. No events are sent for a subscription after its cancel call returns.

A subscription can be scoped to up to 25 merchant domains (`domains`) or pinned to one variant configuration (`selection`).

## Event types

Every event is a JSON object with an envelope plus a `data` payload:

| Field | Description |
| - | - |
| `id` | Unique event ID. Stable across redeliveries — dedupe on it. |
| `timestamp` | ISO 8601 time the change was committed. |
| `type` | The event type. |
| `data.subscription_id` | The subscription this event matched. |
| `data.subject` | What the event is about: `product_id`, `domain` (the merchant domain, matching `domain` on the product's offers), `locale`, and `selection` (the pinned configuration, or `null` for the offer as a whole). |

Events fire only for changes visible in your subscription's scope: the domains you restricted to, in the subscription's locale (currently `en-US` / `USD` for all subscriptions).

For the interactive schemas, see our [webhook event catalog](https://webhooks.trychannel3.com/).

### `offer.price.changed`

The offer's price changed. Any price field changing fires this event, including `compare_at_price` (e.g. a strikethrough price removed).

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "3b7f1c2d4e5a6b8c9d0e1f2a3b4c5d6e7f8a9b0c",
  "timestamp": "2026-10-25T14:03:11.592000+00:00",
  "type": "offer.price.changed",
  "data": {
    "subscription_id": "01JZK3V1M0T4QF3Y3Z0E5T6V7W",
    "subject": {
      "product_id": "2yh8WH5",
      "domain": "nike.com",
      "locale": { "language": "en", "country": "US", "currency": "USD" },
      "selection": null
    },
    "previous": { "price": 189.99, "compare_at_price": null, "currency": "USD" },
    "current": { "price": 149.99, "compare_at_price": 189.99, "currency": "USD" }
  }
}
```

### `offer.availability.changed`

The offer's availability flipped between `InStock` and `OutOfStock`. For subscriptions with a `selection`, this is the pinned configuration's availability; otherwise it's the listing's. Use it for back-in-stock and sold-out UX.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "8c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d",
  "timestamp": "2026-10-25T14:03:11.592000+00:00",
  "type": "offer.availability.changed",
  "data": {
    "subscription_id": "01JZK3V1M0T4QF3Y3Z0E5T6V7W",
    "subject": {
      "product_id": "2yh8WH5",
      "domain": "nike.com",
      "locale": { "language": "en", "country": "US", "currency": "USD" },
      "selection": [{ "name": "Size", "label": "L" }]
    },
    "previous": "OutOfStock",
    "current": "InStock"
  }
}
```

### `offer.created`

An offer entered the subscription's view — for example a merchant started selling the product, or an offer appeared on one of your `domains`. `data.offer` is the current state of the offer.

### `offer.deleted`

An offer left the subscription's view — the merchant delisted it, the page 404s, or it's otherwise no longer available from that merchant. This is the event to listen for if you need to remove or tombstone offers in your own UI. `data.offer` is the last known state of the offer.

```json theme={"theme":{"light":"github-light","dark":"github-dark"}}
{
  "id": "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b",
  "timestamp": "2026-10-25T14:03:11.592000+00:00",
  "type": "offer.deleted",
  "data": {
    "subscription_id": "01JZK3V1M0T4QF3Y3Z0E5T6V7W",
    "subject": {
      "product_id": "2yh8WH5",
      "domain": "nike.com",
      "locale": { "language": "en", "country": "US", "currency": "USD" },
      "selection": null
    },
    "offer": {
      "url": "https://buy.trychannel3.com/2yh8WH5-mm2j9k-vq8x1z?o=01JZK3V1M0T4QF3Y3Z0E5T6V7W",
      "domain": "nike.com",
      "price": { "price": 149.99, "compare_at_price": 189.99, "currency": "USD" },
      "availability": "OutOfStock",
      "condition": "new"
    }
  }
}
```

The offer snapshot's `url` is a Channel3 buy URL that redirects to the merchant's offer page. `offer.created` has the same shape, with the current (rather than last known) state.

## Delivery, retries, and deduplication

Channel3 delivers webhooks through [Svix](https://www.svix.com), a dedicated webhook provider.

**Respond `2xx` within 15 seconds.** Any other status code — including `3xx` redirects — or a slower response counts as a failed attempt. Process events asynchronously if your handler does heavy work.

**Retries.** Failed deliveries are retried automatically on this schedule: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, and twice more at 10-hour intervals. To tell Channel3 to stop retrying a specific message (for example, one your system can't process), include the header `webhook-delivery: abort-message` in your response. You can also replay or recover messages manually from the webhook portal at any time. An endpoint whose deliveries keep failing for days is disabled automatically; re-enable it from the portal.

**Deduplication.** Delivery is at-least-once: retries and manual replays can deliver the same event more than once. Every redelivery carries the same event `id`, which is also registered with Svix as the message's event ID. Keep a short-lived store of recently processed IDs and skip duplicates.

**Ordering.** Use the envelope `timestamp` (when the change was committed) rather than arrival order if you need to sequence events.

## Verifying signatures

Every webhook request is signed so you can verify it genuinely came from Channel3. The scheme is HMAC-SHA256 in the [Standard Webhooks](https://www.standardwebhooks.com/) format — not a raw `sha256=<hex>` digest of the body. Each endpoint you register gets its own signing secret, shown in the portal, starting with `whsec_`.

Each request includes three headers:

| Header | Description |
| - | - |
| `svix-id` | Unique message ID. |
| `svix-timestamp` | Unix timestamp (seconds) of the delivery attempt. |
| `svix-signature` | One or more space-separated `v1,<base64>` signatures. |

The signed content is `{svix-id}.{svix-timestamp}.{raw request body}`, HMAC-SHA256'd with the base64-decoded secret (the part after `whsec_`), then base64-encoded. Signatures must be compared in constant time, and timestamps more than five minutes old (or in the future) must be rejected to prevent replay attacks.

The easiest way to get all of this right is the official Svix SDK:

```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
# pip install svix
from svix.webhooks import Webhook, WebhookVerificationError

webhook = Webhook("whsec_...")  # your endpoint's signing secret

@app.post("/webhooks/channel3")
async def receive(request: Request):
    payload = await request.body()
    try:
        event = webhook.verify(payload, dict(request.headers))
    except WebhookVerificationError:
        return Response(status_code=400)

    if event["type"] == "offer.availability.changed":
        ...
    return {"received": True}
```

```typescript TypeScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
// npm install svix
import { Webhook } from "svix";

const webhook = new Webhook("whsec_..."); // your endpoint's signing secret

export async function POST(req: Request) {
  const payload = await req.text(); // raw body — do not parse before verifying
  const headers = {
    "svix-id": req.headers.get("svix-id")!,
    "svix-timestamp": req.headers.get("svix-timestamp")!,
    "svix-signature": req.headers.get("svix-signature")!,
  };

  let event: any;
  try {
    event = webhook.verify(payload, headers);
  } catch {
    return new Response("invalid signature", { status: 400 });
  }

  if (event.type === "offer.availability.changed") {
    // ...
  }
  return Response.json({ received: true });
}
```

If you can't use the SDK, any Standard Webhooks verification library works, or you can verify manually — this is the complete scheme:

```python theme={"theme":{"light":"github-light","dark":"github-dark"}}
import base64, hashlib, hmac, time

def verify(headers: dict, raw_body: bytes, whsec: str) -> bool:
    msg_id = headers["svix-id"]
    timestamp = headers["svix-timestamp"]
    if abs(time.time() - float(timestamp)) > 300:  # 5-minute tolerance
        return False
    secret = base64.b64decode(whsec.removeprefix("whsec_") + "==")
    signed = f"{msg_id}.{timestamp}.{raw_body.decode()}".encode()
    expected = base64.b64encode(
        hmac.new(secret, signed, hashlib.sha256).digest()
    ).decode()
    signatures = [
        sig.split(",", 1)[1]
        for sig in headers["svix-signature"].split(" ")
        if sig.startswith("v1,")
    ]
    return any(hmac.compare_digest(expected, sig) for sig in signatures)
```

## Managing endpoints

Endpoints are configured once per account — not per subscription — in the [Channel3 dashboard](https://trychannel3.com/dashboard), which embeds a webhook portal (powered by Svix). From the portal you can:

* Add, pause, or remove endpoints, and rotate each endpoint's signing secret.
* Choose which event types each endpoint receives.
* Inspect delivery attempts and payloads, send test events, and replay or recover failed messages.

**Multiple endpoints are supported.** A common setup is one endpoint for QA and one for production, each with its own URL, signing secret, and event-type selection.

Endpoints are independent of subscriptions: a subscription produces events of its chosen types, and every endpoint that has those types enabled receives them. If you use both product subscriptions and the [Price Tracking API](https://docs.trychannel3.com/guides/price-tracking.md), both deliver to the same endpoints.

### User-Agent and allowlisting

Webhook requests are sent with a `User-Agent` of the form `Svix-Webhooks/<version>`. Per Svix's documentation, the exact string can vary by plan and configuration (self-hosted senders use `Webhooks/<version>`), so match on the `Svix-Webhooks/` prefix rather than the full string. Requests from Svix's hosted service also carry the marker `sender-9YMgn` — note this marker is shared by every company on Svix's hosted service, so a User-Agent rule alone does not identify Channel3; treat it as a coarse filter and rely on [signature verification](#verifying-signatures) to authenticate the sender.

If your firewall or Cloudflare configuration allows it, the more robust option is allowlisting by source IP: Svix publishes the static sender IPs for each region at [docs.svix.com/receiving/source-ips](https://docs.svix.com/receiving/source-ips).

## Limits

* Up to **25 merchant domains** per subscription.
* Up to **100 subscriptions per page** when listing.
* There is currently no cap on the number of subscriptions per account.
* Subscribing to a product schedules a priority refresh of that product once per day; events fire when refreshes (or other catalog activity) detect changes.
* API calls to `/v1/product-subscriptions` count against the standard [rate limits](https://docs.trychannel3.com/faq/rate-limits.md) for your plan.

Product subscriptions are free — no credits are charged today. The legacy [Price Tracking API](https://docs.trychannel3.com/guides/price-tracking.md) remains billed at 1 credit per product per day, as listed on the [pricing page](https://docs.trychannel3.com/pricing.md).

> **[Price Tracking Webhooks](https://docs.trychannel3.com/price-tracking-webhooks.md)**
>
> Payload reference for the Price Tracking API's `price.changed` event.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.