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

# Reward delivery modes

> Hand rewards over at your counter, ship them, or deliver a code or link instantly — and cap how many go out

Every reward has a `delivery_mode` that decides what happens the moment a customer spends their points:

| Mode                 | The customer gets                                                             | Points leave their balance     | Your job                                             |
| -------------------- | ----------------------------------------------------------------------------- | ------------------------------ | ---------------------------------------------------- |
| `in_store` (default) | A short redemption code / QR to show at your counter                          | When **you** complete the code | Validate and complete the code                       |
| `shipping`           | A confirmation that their order is on its way                                 | **Immediately**, at redemption | Post the parcel, then advance the fulfillment status |
| `digital`            | The code or link itself, instantly in the app (and by email if you enable it) | **Immediately**, at redemption | Nothing — Rigaly delivers it                         |

The mode is set per reward, so one business can mix all three.

## Which mode for which business

* **A café, salon, gym — anywhere with a counter.** `in_store`. The customer redeems in the app, shows the code, your staff (or your POS integration) completes it. Points are only spent once the reward is actually handed over.
* **An online store shipping physical goods.** `shipping`. A limited-run shirt, a sample box, branded merch. The customer submits their address in the app; you get a fulfillment queue over the API.
* **A brand with no storefront, or anything deliverable as text.** `digital`. A discount code for your checkout, a gift-card code, a link to a private download or a members-only page. Nothing to ship, nothing to staff. One value for everyone, a batch of one-time codes, or a code minted per customer — see [provisioning sources](#digital-provisioning-sources).

<Note>
  `shipping` and `digital` deduct the points **at redemption** and create the redemption already `completed` — there is no code to validate afterwards. That's the tradeoff for delivering without a terminal: the customer commits their points up front.
</Note>

## In store

Unchanged, and still the default — create a reward without `delivery_mode` and you get exactly today's behavior. The customer redeems in the app, gets an 8-character code (or a QR), and you consume it at checkout. Full walkthrough: [Issue points & validate rewards](/guides/issue-and-validate).

## Digital

Configure what the customer receives on the reward itself:

| Field                    |                                                                                                                                             |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `digital_asset_type`     | `"code"` or `"link"` — required for digital rewards                                                                                         |
| `digital_asset_source`   | `"static"` (default), `"pool"` or `"external"` — where the value comes from. See [provisioning sources](#digital-provisioning-sources)      |
| `digital_asset_value`    | The code or URL, 1–500 characters. Must be a valid `http(s)` URL when the type is `link`. Required for `static`, rejected for the other two |
| `digital_webhook_url`    | Your HTTPS endpoint, up to 500 characters. Required for `external`, rejected otherwise                                                      |
| `digital_email_delivery` | `true` also emails it to the customer; either way it's shown in the app                                                                     |

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.rigaly.com/api/v1/rewards \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "20% off your next order",
      "description": "Use this code at checkout on our online store.",
      "points_cost": 3000,
      "delivery_mode": "digital",
      "digital_asset_type": "code",
      "digital_asset_value": "RIGALY20",
      "digital_email_delivery": true
    }'
  ```

  ```javascript Node.js theme={null}
  const { data } = await fetch("https://api.rigaly.com/api/v1/rewards", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RIGALY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "20% off your next order",
      description: "Use this code at checkout on our online store.",
      points_cost: 3000,
      delivery_mode: "digital",
      digital_asset_type: "code",     // or "link"
      digital_asset_value: "RIGALY20", // must be an http(s) URL when type is "link"
      digital_email_delivery: true,
    }),
  }).then((r) => r.json());
  ```

  ```python Python theme={null}
  reward = requests.post(
      "https://api.rigaly.com/api/v1/rewards",
      headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
      json={
          "name": "20% off your next order",
          "description": "Use this code at checkout on our online store.",
          "points_cost": 3000,
          "delivery_mode": "digital",
          "digital_asset_type": "code",
          "digital_asset_value": "RIGALY20",
          "digital_email_delivery": True,
      },
  ).json()["data"]["reward"]
  ```

  ```php PHP theme={null}
  <?php
  $ch = curl_init("https://api.rigaly.com/api/v1/rewards");
  curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => [
          "Authorization: Bearer " . getenv("RIGALY_API_KEY"),
          "Content-Type: application/json",
      ],
      CURLOPT_POSTFIELDS => json_encode([
          "name" => "20% off your next order",
          "points_cost" => 3000,
          "delivery_mode" => "digital",
          "digital_asset_type" => "code",
          "digital_asset_value" => "RIGALY20",
          "digital_email_delivery" => true,
      ]),
  ]);
  $reward = json_decode(curl_exec($ch), true)["data"]["reward"];
  ```
</CodeGroup>

Rules worth knowing:

* The `digital_*` fields are **only accepted on digital rewards** — send them together with `delivery_mode: "digital"` or not at all. `digital_asset_type` is always required.
* **Editing the reward never rewrites what someone already received.** The value is snapshotted onto each redemption, so changing `digital_asset_value` next month only affects redemptions from that point on.
* `digital_asset_value` and `digital_webhook_url` are returned on your own reward reads. They are never exposed on customer-facing payloads.

## Digital provisioning sources

The example above is the simplest kind of digital reward: one code, handed to
everyone. `digital_asset_source` changes **where the value comes from** — and
nothing else. `delivery_mode` stays `digital`, the customer still gets their code
or link instantly in the app, and the points still leave their balance at
redemption. Only your side of the wall changes.

| Source             | Where the value comes from                                      | Every customer gets                          | Reach for it when                                                                                                      |
| ------------------ | --------------------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `static` (default) | The reward's own `digital_asset_value`                          | **The same value**                           | One coupon code works for everyone — `SUMMER24` at your checkout, a link to a members-only page, a PDF download        |
| `pool`             | A batch of one-time values you upload to the reward             | **A different value**, consumed exactly once | You have a finite list of unique codes — gift cards, licence keys, cinema vouchers, codes a partner issued you in bulk |
| `external`         | An HTTPS endpoint of yours that Rigaly calls at redemption time | **A freshly minted value**                   | Your system generates codes on demand, or the code must be tied to that specific customer at that moment               |

<Note>
  Pick by **who owns the codes**. If you already hold the list, upload it as a
  `pool` — nothing to build, nothing to keep online. If the codes only exist once
  something asks for them, go `external`. `static` is for the case where there is
  genuinely only one value.
</Note>

Reward reads report the source and its state:

```jsonc theme={null}
{
  "delivery_mode": "digital",
  "digital_asset_type": "code",
  "digital_asset_source": "pool",
  "digital_asset_value": null,      // static only
  "digital_webhook_url": null,      // external only
  "pool_remaining": 4863,           // pool rewards only
  "deactivated_by_pool": false,
  "deactivated_by_limit": false
}
```

`digital_webhook_secret` is **not** in that list, and never will be — see
[the signing secret](#the-signing-secret).

### Static values

What the [example above](#digital) does, and the default if you never send
`digital_asset_source`. One value in `digital_asset_value`, the same for every
customer, changed whenever you like. Rigaly does not generate it and does not
track how many times it has gone out — that is what `max_redemptions` is for.

### Pool of one-time values

A pool is a stored batch of values belonging to one reward. **Each redemption
consumes exactly one, atomically** — the claim happens inside the database, so
two customers redeeming at the same instant can never be handed the same value.
There is no window in which a code is "reserved but not yet spent" for two people
at once.

The lifecycle:

```
upload ──► available ──consume──► consumed (permanent)
              │
              └── available hits 0 ──► reward auto-off (deactivated_by_pool: true)
                                              │
                                    upload more ──► reward reopens
```

#### 1. Create the reward

Send `digital_asset_source: "pool"` and **no** `digital_asset_value` — the pool
supplies it.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.rigaly.com/api/v1/rewards \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "$10 gift card",
      "description": "A one-time code, yours alone.",
      "points_cost": 10000,
      "delivery_mode": "digital",
      "digital_asset_type": "code",
      "digital_asset_source": "pool",
      "digital_email_delivery": true
    }'
  ```

  ```javascript Node.js theme={null}
  const { data } = await fetch("https://api.rigaly.com/api/v1/rewards", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RIGALY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "$10 gift card",
      points_cost: 10000,
      delivery_mode: "digital",
      digital_asset_type: "code",
      digital_asset_source: "pool", // no digital_asset_value — the pool supplies it
      digital_email_delivery: true,
    }),
  }).then((r) => r.json());

  const rewardId = data.reward.id;
  // data.reward.active === false, deactivated_by_pool === true — until you upload
  ```

  ```python Python theme={null}
  reward = requests.post(
      "https://api.rigaly.com/api/v1/rewards",
      headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
      json={
          "name": "$10 gift card",
          "points_cost": 10000,
          "delivery_mode": "digital",
          "digital_asset_type": "code",
          "digital_asset_source": "pool",
          "digital_email_delivery": True,
      },
  ).json()["data"]["reward"]
  ```

  ```php PHP theme={null}
  <?php
  $ch = curl_init("https://api.rigaly.com/api/v1/rewards");
  curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => [
          "Authorization: Bearer " . getenv("RIGALY_API_KEY"),
          "Content-Type: application/json",
      ],
      CURLOPT_POSTFIELDS => json_encode([
          "name" => "\$10 gift card",
          "points_cost" => 10000,
          "delivery_mode" => "digital",
          "digital_asset_type" => "code",
          "digital_asset_source" => "pool",
          "digital_email_delivery" => true,
      ]),
  ]);
  $reward = json_decode(curl_exec($ch), true)["data"]["reward"];
  ```
</CodeGroup>

A brand-new pool reward always comes back **switched off** — `active: false`,
`deactivated_by_pool: true`. There is nothing to hand out yet, so asking for
`active: true` doesn't fail; it is simply parked, and **the first upload opens
it**. Create it with `active: false` instead and it stays off until you say
otherwise, upload or no upload.

#### 2. Upload the values

Up to **5,000 values per request**, each 1–500 characters, up to a **capacity of
100,000 stored values** per reward. Send as many requests as the list needs.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.rigaly.com/api/v1/rewards/{rewardId}/pool/codes \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{"codes": ["GC-A1B2C3", "GC-D4E5F6", "GC-G7H8J9"]}'
  ```

  ```javascript Node.js theme={null}
  // Chunk a long list — 5,000 values per request
  async function uploadPool(rewardId, codes) {
    let stats;
    for (let i = 0; i < codes.length; i += 5000) {
      const response = await fetch(
        `https://api.rigaly.com/api/v1/rewards/${rewardId}/pool/codes`,
        {
          method: "POST",
          headers: {
            Authorization: `Bearer ${process.env.RIGALY_API_KEY}`,
            "Content-Type": "application/json",
          },
          body: JSON.stringify({ codes: codes.slice(i, i + 5000) }),
        }
      );
      stats = (await response.json()).data;
      console.log(`${stats.accepted}/${stats.submitted} accepted, ${stats.available} available`);
    }
    return stats;
  }
  ```

  ```python Python theme={null}
  def upload_pool(reward_id, codes):
      stats = None
      for i in range(0, len(codes), 5000):
          stats = requests.post(
              f"https://api.rigaly.com/api/v1/rewards/{reward_id}/pool/codes",
              headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
              json={"codes": codes[i : i + 5000]},
          ).json()["data"]
          print(f"{stats['accepted']}/{stats['submitted']} accepted, {stats['available']} available")
      return stats
  ```

  ```php PHP theme={null}
  <?php
  function uploadPool(string $rewardId, array $codes): array {
      $stats = [];
      foreach (array_chunk($codes, 5000) as $chunk) {
          $ch = curl_init("https://api.rigaly.com/api/v1/rewards/{$rewardId}/pool/codes");
          curl_setopt_array($ch, [
              CURLOPT_POST => true,
              CURLOPT_RETURNTRANSFER => true,
              CURLOPT_HTTPHEADER => [
                  "Authorization: Bearer " . getenv("RIGALY_API_KEY"),
                  "Content-Type: application/json",
              ],
              CURLOPT_POSTFIELDS => json_encode(["codes" => $chunk]),
          ]);
          $stats = json_decode(curl_exec($ch), true)["data"];
      }
      return $stats;
  }
  ```
</CodeGroup>

The response is the full accounting — what happened to your values, and where the
pool now stands:

```jsonc theme={null}
{
  "reward_id": "3f2b1c9e-…",
  "submitted": 5000,
  "accepted": 4990,
  "rejected_duplicates_in_request": 4,   // the same value twice in your payload
  "rejected_duplicates_existing": 6,     // the pool already held it
  "rejected_invalid": 0,                 // blank, or over 500 characters
  "total": 4990,                         // available + consumed
  "available": 4990,
  "consumed": 0,
  "capacity": 100000
}
```

<Note>
  **Uploading is idempotent by value.** A value the pool already holds is counted
  and skipped, never stored twice — so re-sending a file you're not sure went
  through can't double-load your codes. If nothing in the request was usable you
  get `POOL_UPLOAD_EMPTY`; if it would push `total` past `capacity` you get
  `POOL_CAPACITY_EXCEEDED:<max>` and **nothing is stored**.
</Note>

#### 3. Watch it drain

<CodeGroup>
  ```bash curl theme={null}
  curl https://api.rigaly.com/api/v1/rewards/{rewardId}/pool \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY"
  ```

  ```javascript Node.js theme={null}
  const { data } = await fetch(
    `https://api.rigaly.com/api/v1/rewards/${rewardId}/pool`,
    { headers: { Authorization: `Bearer ${process.env.RIGALY_API_KEY}` } }
  ).then((r) => r.json());

  if (data.available < 100) {
    await notifyOpsToRefill(rewardId, data.available);
  }
  ```

  ```python Python theme={null}
  pool = requests.get(
      f"https://api.rigaly.com/api/v1/rewards/{reward_id}/pool",
      headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
  ).json()["data"]

  if pool["available"] < 100:
      notify_ops_to_refill(reward_id, pool["available"])
  ```

  ```php PHP theme={null}
  <?php
  $ch = curl_init("https://api.rigaly.com/api/v1/rewards/{$rewardId}/pool");
  curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . getenv("RIGALY_API_KEY")]);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  $pool = json_decode(curl_exec($ch), true)["data"];

  if ($pool["available"] < 100) {
      notifyOpsToRefill($rewardId, $pool["available"]);
  }
  ```
</CodeGroup>

```jsonc theme={null}
{
  "reward_id": "3f2b1c9e-…",
  "total": 5000,
  "available": 4863,
  "consumed": 137,
  "capacity": 100000
}
```

The same `available` figure rides along on every reward read as `pool_remaining`,
so a dashboard that already lists rewards doesn't need a second call.

#### 4. Empty means off — refilling turns it back on

At `available: 0` the reward switches itself off: `active: false`,
`deactivated_by_pool: true`, and it disappears from the customer app. **Upload
more values and it comes back on by itself.**

This is the same mechanic as the redemption cap, and the two are entirely
independent:

|                        | Switches the reward off when                  | Clears when                                     |
| ---------------------- | --------------------------------------------- | ----------------------------------------------- |
| `deactivated_by_limit` | `redemptions_count` reaches `max_redemptions` | You raise `max_redemptions` or set it to `null` |
| `deactivated_by_pool`  | The pool runs dry                             | You upload more values                          |

Both can be true at once — a reward that hit its cap *and* emptied its pool. It
only comes back when **neither** is blocking: refilling a pool on a reward that is
still at its cap leaves it off (and vice versa). A reward *you* switched off by
hand stays off regardless, and toggling it back on by hand clears both flags —
provided there is stock and headroom to do it with.

#### 5. Deleting and exporting

Delete unconsumed values — the ones you name, or all of them when you send no
body:

```bash theme={null}
# Retire three codes a partner revoked
curl -X DELETE https://api.rigaly.com/api/v1/rewards/{rewardId}/pool/codes \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"values": ["GC-A1B2C3", "GC-D4E5F6", "GC-G7H8J9"]}'

# Clear the whole unconsumed remainder
curl -X DELETE https://api.rigaly.com/api/v1/rewards/{rewardId}/pool/codes \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY"
```

<Warning>
  **Consumed values can never be deleted or edited.** They are what a customer
  already received, so they stay on the pool permanently — they keep counting
  toward `total` and `consumed`, and a value you ask to delete that had already
  gone out simply won't appear in the response's `deleted` count.
</Warning>

Export the whole pool for reconciliation — which code went to which redemption:

```bash theme={null}
curl "https://api.rigaly.com/api/v1/rewards/{rewardId}/pool/export?format=csv" \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
  -o gift-card-pool.csv
```

```csv theme={null}
value,status,consumed_at,redemption_id
"GC-A1B2C3","consumed","2026-08-01T14:22:31.004Z","9f1c3a2e-5b7d-4e21-9a0c-1f2d3e4b5a67"
"GC-D4E5F6","available","",""
```

`format=json` streams the same rows as an array of
`{ value, status, consumed_at, redemption_id }`. Both formats include consumed
and available values, oldest first.

### External provisioning

Set `digital_asset_source: "external"` and give Rigaly an HTTPS endpoint. At
redemption time Rigaly `POST`s to it, your service mints a value, and whatever it
returns is what the customer sees — a code tied to that customer, a signed
download link, a licence generated on the spot.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.rigaly.com/api/v1/rewards \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "name": "One month of Premium",
      "points_cost": 15000,
      "delivery_mode": "digital",
      "digital_asset_type": "code",
      "digital_asset_source": "external",
      "digital_webhook_url": "https://api.example.com/rigaly/provision"
    }'
  ```

  ```javascript Node.js theme={null}
  const { data } = await fetch("https://api.rigaly.com/api/v1/rewards", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.RIGALY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      name: "One month of Premium",
      points_cost: 15000,
      delivery_mode: "digital",
      digital_asset_type: "code",
      digital_asset_source: "external",
      digital_webhook_url: "https://api.example.com/rigaly/provision",
    }),
  }).then((r) => r.json());

  // Shown exactly once — store it now, no read endpoint returns it again
  await saveToSecretStore(data.reward.digital_webhook_secret);
  ```

  ```python Python theme={null}
  reward = requests.post(
      "https://api.rigaly.com/api/v1/rewards",
      headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
      json={
          "name": "One month of Premium",
          "points_cost": 15000,
          "delivery_mode": "digital",
          "digital_asset_type": "code",
          "digital_asset_source": "external",
          "digital_webhook_url": "https://api.example.com/rigaly/provision",
      },
  ).json()["data"]["reward"]

  save_to_secret_store(reward["digital_webhook_secret"])  # shown exactly once
  ```

  ```php PHP theme={null}
  <?php
  $ch = curl_init("https://api.rigaly.com/api/v1/rewards");
  curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => [
          "Authorization: Bearer " . getenv("RIGALY_API_KEY"),
          "Content-Type: application/json",
      ],
      CURLOPT_POSTFIELDS => json_encode([
          "name" => "One month of Premium",
          "points_cost" => 15000,
          "delivery_mode" => "digital",
          "digital_asset_type" => "code",
          "digital_asset_source" => "external",
          "digital_webhook_url" => "https://api.example.com/rigaly/provision",
      ]),
  ]);
  $reward = json_decode(curl_exec($ch), true)["data"]["reward"];

  saveToSecretStore($reward["digital_webhook_secret"]); // shown exactly once
  ```
</CodeGroup>

#### The signing secret

Creating a reward as `external`, or switching an existing reward to `external`,
returns `digital_webhook_secret` on the reward object — **once**. No read endpoint
ever returns it. Editing only the URL of an already-external reward does *not*
rotate it, so an endpoint move needs no redeploy of your verification code.

Lost it, or rotating on a schedule? Issue a new one:

```bash theme={null}
curl -X POST https://api.rigaly.com/api/v1/rewards/{rewardId}/webhook/rotate-secret \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY"
```

```json theme={null}
{
  "success": true,
  "message": "Signing secret rotated. Store it now — it is never shown again.",
  "data": { "digital_webhook_secret": "8c1f0a94d3e27b65…" }
}
```

<Warning>
  The previous secret stops working **immediately**. Deploy the new one before you
  rotate, or accept a gap in which your endpoint rejects Rigaly's signature — those
  redemptions fail, and while no customer is charged for them, they do see the
  reward refuse to deliver.
</Warning>

#### The request Rigaly sends

One `POST`, `Content-Type: application/json`, per redemption:

| Header               |                           |
| -------------------- | ------------------------- |
| `X-Rigaly-Event`     | Always `reward.provision` |
| `X-Rigaly-Timestamp` | Unix seconds              |
| `X-Rigaly-Signature` | `t=<unix>,v1=<hex>`       |
| `Idempotency-Key`    | The redemption's UUID     |
| `User-Agent`         | `Rigaly-Webhooks/1.0`     |

```json theme={null}
{
  "event": "reward.provision",
  "test": false,
  "idempotency_key": "9f1c3a2e-5b7d-4e21-9a0c-1f2d3e4b5a67",
  "redemption_id": "9f1c3a2e-5b7d-4e21-9a0c-1f2d3e4b5a67",
  "reward_id": "3f2b1c9e-7d4a-4f1b-8c2e-9a0d1e2f3b4c",
  "business_id": "7a8b9c0d-1e2f-3a4b-5c6d-7e8f9a0b1c2d",
  "user_id": "1b2c3d4e-5f60-4718-9a2b-3c4d5e6f7081",
  "asset_type": "code",
  "requested_at": "2026-08-05T12:00:00.000Z"
}
```

**IDs only, never PII.** No name, no email, no phone. If your provisioning logic
needs the customer's details, look them up with
`GET /api/v1/customers/{user_id}/balance` or your own record keyed on `user_id`.

#### Verifying the signature

`X-Rigaly-Signature` is `t=<unix>,v1=<hex>`, where the hex is:

```
HMAC_SHA256(secret, "{t}.{raw request body}")
```

The Stripe convention, and the same three rules apply: **sign the raw bytes**
(re-serializing the parsed JSON will not reproduce the digest), **compare in
constant time**, and **reject stale timestamps** so a captured request can't be
replayed at leisure.

<CodeGroup>
  ```bash openssl theme={null}
  # Recompute the digest by hand when a signature won't verify.
  # $T is the t= value from the header, $BODY the raw body exactly as received.
  printf '%s' "$T.$BODY" \
    | openssl dgst -sha256 -hmac "$RIGALY_WEBHOOK_SECRET" -hex
  # → compare with the v1= value in X-Rigaly-Signature
  ```

  ```javascript Node.js theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const app = express();

  // Raw body — do NOT use express.json() before verifying
  app.post(
    "/rigaly/provision",
    express.raw({ type: "application/json" }),
    async (req, res) => {
      const header = req.get("X-Rigaly-Signature") ?? "";
      const parts = Object.fromEntries(
        header.split(",").map((p) => p.split("=", 2))
      );
      const { t, v1 } = parts;

      // 1. Reject stale timestamps (5-minute window)
      if (!t || Math.abs(Date.now() / 1000 - Number(t)) > 300) {
        return res.status(400).json({ error: "stale timestamp" });
      }

      // 2. Recompute over "{t}.{raw body}"
      const expected = crypto
        .createHmac("sha256", process.env.RIGALY_WEBHOOK_SECRET)
        .update(`${t}.${req.body.toString("utf8")}`)
        .digest("hex");

      // 3. Constant-time compare
      const ok =
        v1 &&
        v1.length === expected.length &&
        crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
      if (!ok) return res.status(401).json({ error: "bad signature" });

      const event = JSON.parse(req.body.toString("utf8"));

      // 4. Same key → same value. Always.
      const value = await mintOrReplay(event.idempotency_key, event);

      res.json({ type: "code", value, expires_at: "2026-12-31T23:59:59Z" });
    }
  );
  ```

  ```python Python theme={null}
  import hashlib, hmac, os, time
  from flask import Flask, request, jsonify

  app = Flask(__name__)
  SECRET = os.environ["RIGALY_WEBHOOK_SECRET"].encode()

  @app.post("/rigaly/provision")
  def provision():
      header = request.headers.get("X-Rigaly-Signature", "")
      parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
      t, v1 = parts.get("t"), parts.get("v1")

      # 1. Reject stale timestamps (5-minute window)
      if not t or abs(time.time() - int(t)) > 300:
          return jsonify(error="stale timestamp"), 400

      # 2. Recompute over "{t}.{raw body}"
      raw = request.get_data()  # bytes, exactly as received
      expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()

      # 3. Constant-time compare
      if not v1 or not hmac.compare_digest(v1, expected):
          return jsonify(error="bad signature"), 401

      event = request.get_json()

      # 4. Same key → same value. Always.
      value = mint_or_replay(event["idempotency_key"], event)

      return jsonify(type="code", value=value, expires_at="2026-12-31T23:59:59Z")
  ```

  ```php PHP theme={null}
  <?php
  $raw = file_get_contents("php://input"); // raw body, exactly as received
  $header = $_SERVER["HTTP_X_RIGALY_SIGNATURE"] ?? "";

  $parts = [];
  foreach (explode(",", $header) as $piece) {
      [$k, $v] = array_pad(explode("=", $piece, 2), 2, null);
      $parts[$k] = $v;
  }
  $t  = $parts["t"]  ?? null;
  $v1 = $parts["v1"] ?? null;

  // 1. Reject stale timestamps (5-minute window)
  if (!$t || abs(time() - (int) $t) > 300) {
      http_response_code(400);
      exit(json_encode(["error" => "stale timestamp"]));
  }

  // 2. Recompute over "{t}.{raw body}"
  $expected = hash_hmac("sha256", $t . "." . $raw, getenv("RIGALY_WEBHOOK_SECRET"));

  // 3. Constant-time compare
  if (!$v1 || !hash_equals($expected, $v1)) {
      http_response_code(401);
      exit(json_encode(["error" => "bad signature"]));
  }

  $event = json_decode($raw, true);

  // 4. Same key → same value. Always.
  $value = mintOrReplay($event["idempotency_key"], $event);

  header("Content-Type: application/json");
  echo json_encode([
      "type" => "code",
      "value" => $value,
      "expires_at" => "2026-12-31T23:59:59Z",
  ]);
  ```
</CodeGroup>

#### What you must return

Any `2xx`, with a JSON body:

```json theme={null}
{
  "type": "code",
  "value": "NETFLIX-XYZ123",
  "expires_at": "2026-12-31T23:59:59Z"
}
```

| Field        |                                                                                                                                                                       |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`       | Required. Must **equal the reward's `digital_asset_type`** — returning `link` for a `code` reward is rejected outright                                                |
| `value`      | Required. Non-empty, at most 500 characters, and an `http(s)` URL when `type` is `link`                                                                               |
| `expires_at` | Optional. ISO 8601, and **must be in the future**. Delivered to the customer as the asset's `expires_at` and stored on the redemption as `delivered_asset_expires_at` |

Anything else — a non-2xx, a missing field, a type mismatch, an `expires_at` in
the past — fails the provisioning call.

<Warning>
  **The same `Idempotency-Key` must always return the same value.** Store the key
  alongside the value you minted and replay it verbatim on a repeat call; never
  mint a second one. A network blip that makes Rigaly's request look failed to you
  but succeeded to us, a retry you trigger yourself, a duplicate delivery — all of
  them are harmless if and only if the key is your unit of truth. Get this wrong
  and you hand out two codes for one redemption.
</Warning>

#### The operational envelope

Design your endpoint to fit inside it:

|                      |                                                                                                                                                                                                                     |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Transport            | **HTTPS only.** Redirects are not followed — publish the final URL                                                                                                                                                  |
| Timeout              | **5 seconds**, and **no retries within the redemption**. Slow is the same as broken; mint asynchronously ahead of time if you can't answer fast                                                                     |
| Response size        | Capped at **64 KB**                                                                                                                                                                                                 |
| Circuit breaker      | **5 consecutive failures on one reward → that reward stops calling out for 60 seconds.** One broken integration can't take your whole endpoint down with retry pressure                                             |
| Blocked destinations | Private, loopback, link-local and cloud-metadata addresses are refused — at save time and again at call time, against the address actually connected to. A public hostname that resolves inward will not be reached |
| Error visibility     | Your response body is never shown to the customer. They see a generic failure; you see the detail on the [test endpoint](#testing-before-you-ship)                                                                  |

#### Testing before you ship

`POST /rewards/{id}/webhook/test` runs the real signed call against your endpoint
without creating a redemption, moving points or charging a fee. The body carries
`test: true` — return a throwaway value, not real stock.

<CodeGroup>
  ```bash curl theme={null}
  # Test the saved endpoint…
  curl -X POST https://api.rigaly.com/api/v1/rewards/{rewardId}/webhook/test \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY"

  # …or a candidate you haven't saved yet
  curl -X POST https://api.rigaly.com/api/v1/rewards/{rewardId}/webhook/test \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{"url": "https://staging.example.com/rigaly/provision"}'
  ```

  ```javascript Node.js theme={null}
  const { data } = await fetch(
    `https://api.rigaly.com/api/v1/rewards/${rewardId}/webhook/test`,
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.RIGALY_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ url: "https://staging.example.com/rigaly/provision" }),
    }
  ).then((r) => r.json());

  // HTTP 200 even on failure — `ok` is the real result
  if (!data.ok) {
    console.error(data.error_code, data.error, data.request_preview);
  }
  ```

  ```python Python theme={null}
  data = requests.post(
      f"https://api.rigaly.com/api/v1/rewards/{reward_id}/webhook/test",
      headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
      json={"url": "https://staging.example.com/rigaly/provision"},
  ).json()["data"]

  if not data["ok"]:  # HTTP 200 even on failure — `ok` is the real result
      print(data["error_code"], data["error"], data["request_preview"])
  ```

  ```php PHP theme={null}
  <?php
  $ch = curl_init("https://api.rigaly.com/api/v1/rewards/{$rewardId}/webhook/test");
  curl_setopt_array($ch, [
      CURLOPT_POST => true,
      CURLOPT_RETURNTRANSFER => true,
      CURLOPT_HTTPHEADER => [
          "Authorization: Bearer " . getenv("RIGALY_API_KEY"),
          "Content-Type: application/json",
      ],
      CURLOPT_POSTFIELDS => json_encode([
          "url" => "https://staging.example.com/rigaly/provision",
      ]),
  ]);
  $data = json_decode(curl_exec($ch), true)["data"];

  if (!$data["ok"]) { // HTTP 200 even on failure — `ok` is the real result
      error_log($data["error_code"] . " " . $data["error"]);
  }
  ```
</CodeGroup>

<Note>
  **The test endpoint answers HTTP 200 even when the test failed.** A transport
  failure on your side isn't an API error on ours, so read `ok` — never the status
  code — and expect `error_code` / `error` to explain the rest.
</Note>

```jsonc theme={null}
{
  "ok": false,
  "http_status": 200,
  "latency_ms": 148,
  "asset": null,
  "error_code": "type_mismatch",
  "error": "Endpoint returned type 'link' but the reward delivers 'code'",
  "request_preview": {                  // the exact body that was signed
    "event": "reward.provision",
    "test": true,
    "idempotency_key": "test_9f1c3a2e-…",
    "redemption_id": null,
    "reward_id": "3f2b1c9e-…",
    "business_id": "7a8b9c0d-…",
    "user_id": null,
    "asset_type": "code",
    "requested_at": "2026-08-05T12:00:00.000Z"
  }
}
```

`error_code` is one of `timeout`, `network`, `http_status`,
`malformed_response`, `type_mismatch`, `expired_asset`, `circuit_open`, or an
`ssrf_*` code when the URL or the address it resolves to is refused.
`request_preview` is the exact payload that was signed, field for field — check
it against what your handler received when a signature won't verify.

### Nobody pays for a failure

Across all three sources, **points are deducted only after the value is
secured.** The redemption is opened, the value is claimed or minted, and only
then does the customer's balance move.

So when provisioning fails:

* the redemption is **cancelled**,
* **no points** are taken,
* any pool value that was claimed is **released** back to the pool,
* and **no per-transaction fee** is charged.

The customer sees the reward fail to deliver and their balance untouched, which
is the only acceptable outcome for a code you couldn't produce. They can try
again once you've fixed it.

## Shipping

Create the reward with the mode and, usually, a redemption cap — shipped rewards are almost always limited stock:

```bash theme={null}
curl -X POST https://api.rigaly.com/api/v1/rewards \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Limited edition shirt",
    "description": "Ships free anywhere in the country. Allow 5–7 days.",
    "points_cost": 25000,
    "delivery_mode": "shipping",
    "max_redemptions": 50
  }'
```

When a customer redeems it, the app collects their name, phone, email and address, spends the points, and creates the redemption as `completed` with `fulfillment_status: "pending"`. From there it's your queue.

### 1. Poll the queue

<CodeGroup>
  ```bash curl theme={null}
  curl "https://api.rigaly.com/api/v1/redemptions?fulfillment_status=pending&limit=50" \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY"
  ```

  ```javascript Node.js theme={null}
  const params = new URLSearchParams({ fulfillment_status: "pending", limit: "50" });
  const { data } = await fetch(
    `https://api.rigaly.com/api/v1/redemptions?${params}`,
    { headers: { Authorization: `Bearer ${process.env.RIGALY_API_KEY}` } }
  ).then((r) => r.json());

  for (const r of data.redemptions) {
    console.log(`${r.reward_name} → ${r.ship_to_name}, ${r.ship_to_address}`);
  }
  // data.pagination → { page, limit, total, total_pages }
  ```

  ```python Python theme={null}
  data = requests.get(
      "https://api.rigaly.com/api/v1/redemptions",
      headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
      params={"fulfillment_status": "pending", "limit": 50},
  ).json()["data"]

  for r in data["redemptions"]:
      print(f"{r['reward_name']} → {r['ship_to_name']}, {r['ship_to_address']}")
  ```

  ```php PHP theme={null}
  <?php
  $query = http_build_query(["fulfillment_status" => "pending", "limit" => 50]);
  $ch = curl_init("https://api.rigaly.com/api/v1/redemptions?{$query}");
  curl_setopt($ch, CURLOPT_HTTPHEADER, ["Authorization: Bearer " . getenv("RIGALY_API_KEY")]);
  curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
  $data = json_decode(curl_exec($ch), true)["data"];

  foreach ($data["redemptions"] as $r) {
      echo "{$r['reward_name']} → {$r['ship_to_name']}, {$r['ship_to_address']}\n";
  }
  ```
</CodeGroup>

Each row carries the shipping details (`ship_to_name`, `ship_to_phone`, `ship_to_email`, `ship_to_address`), the reward (`reward_id`, `reward_name`, `reward_image_url`, `points_cost`), the customer (`user_id`, `user_name`) and the lifecycle timestamps. Only shipping redemptions appear here — in-store and digital ones are not fulfillment work. Filter by `reward_id` to work one product at a time, and page with `page` / `limit`.

### 2. Advance the status

Use the redemption's `id` (not its code). Send a `tracking_reference` with the shipped step so the customer can follow the parcel in the app:

<CodeGroup>
  ```bash curl theme={null}
  curl -X PATCH https://api.rigaly.com/api/v1/redemptions/{redemptionId}/fulfillment \
    -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
    -H "Content-Type: application/json" \
    -d '{"status": "shipped", "tracking_reference": "1Z999AA10123456784"}'
  ```

  ```javascript Node.js theme={null}
  async function markShipped(redemptionId, tracking) {
    const response = await fetch(
      `https://api.rigaly.com/api/v1/redemptions/${redemptionId}/fulfillment`,
      {
        method: "PATCH",
        headers: {
          Authorization: `Bearer ${process.env.RIGALY_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ status: "shipped", tracking_reference: tracking }),
      }
    );
    return (await response.json()).data.redemption;
  }
  ```

  ```python Python theme={null}
  def mark_shipped(redemption_id, tracking):
      response = requests.patch(
          f"https://api.rigaly.com/api/v1/redemptions/{redemption_id}/fulfillment",
          headers={"Authorization": f"Bearer {os.environ['RIGALY_API_KEY']}"},
          json={"status": "shipped", "tracking_reference": tracking},
      )
      return response.json()["data"]["redemption"]
  ```

  ```php PHP theme={null}
  <?php
  function markShipped(string $redemptionId, string $tracking): array {
      $ch = curl_init("https://api.rigaly.com/api/v1/redemptions/{$redemptionId}/fulfillment");
      curl_setopt_array($ch, [
          CURLOPT_CUSTOMREQUEST => "PATCH",
          CURLOPT_RETURNTRANSFER => true,
          CURLOPT_HTTPHEADER => [
              "Authorization: Bearer " . getenv("RIGALY_API_KEY"),
              "Content-Type: application/json",
          ],
          CURLOPT_POSTFIELDS => json_encode([
              "status" => "shipped",
              "tracking_reference" => $tracking,
          ]),
      ]);
      return json_decode(curl_exec($ch), true)["data"]["redemption"];
  }
  ```
</CodeGroup>

The lifecycle:

```
pending ──shipped──► shipped ──delivered──► delivered
   └────────────── cancelled ──────────────┘
```

* **`shipped`** stamps `shipped_at`.
* **`delivered`** stamps `delivered_at`, and backfills `shipped_at` if you skipped the middle step.
* **`cancelled`** ends the order, **refunds the customer's points**, moves the redemption's own `status` to `cancelled`, and releases the reward's redemption slot — if the reward had been closed by its cap, it reopens. Cancelling a second time returns `FULFILLMENT_ALREADY_CANCELLED`.

### 3. Cancelling refunds the points

Out of stock, undeliverable address, customer changed their mind — cancelling is the way out, and **the points the customer spent go straight back to their balance**. You don't have to credit them yourself.

```bash theme={null}
curl -X PATCH https://api.rigaly.com/api/v1/redemptions/{redemptionId}/fulfillment \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "cancelled"}'
```

The response tells you exactly what was paid out:

```jsonc theme={null}
{
  "id": "…",
  "status": "cancelled",
  "fulfillment_status": "cancelled",
  "points_refunded": 5000   // what went back to the customer, or null
}
```

`points_refunded` is `null` when nothing was owed — a free reward, a customer whose account has since been anonymised, or a redemption that is no longer `completed` because an earlier call already refunded it. It is also always `null` on `shipped` and `delivered`; only a cancellation moves points.

<Note>
  **Cancelling is atomic and idempotent.** The refund and the status change happen inside one database function, so retrying a cancel that already went through pays out nothing a second time — you get `FULFILLMENT_ALREADY_CANCELLED`, never a double credit. Retry freely on a timeout.
</Note>

#### Cancelling without a refund

Send `refund_points: false` when the points should *not* come back:

```bash theme={null}
curl -X PATCH https://api.rigaly.com/api/v1/redemptions/{redemptionId}/fulfillment \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"status": "cancelled", "refund_points": false}'
```

Reach for it when you've already made the customer whole another way — you shipped a replacement item, issued store credit, or refunded cash — and a points refund on top would pay them twice. Also useful when you're closing out an order the customer already received and kept, and you only want the slot and the paperwork cleaned up. The response comes back with `points_refunded: null`.

The reward's redemption slot is released either way: `refund_points` only decides whether the customer's balance moves.

<Warning>
  **Cancelling does not reverse billing.** The per-transaction fee charged when the customer originally claimed the reward stands — refunding the points does not refund the fee. Cancellations are a customer-service action, not a billing reversal.
</Warning>

## Capping how many go out

Any reward, in any mode, takes a `max_redemptions` ceiling — the total number of **completed** redemptions allowed across all customers. `null` (the default) means unlimited.

```bash theme={null}
# 50 shirts, and not one more
curl -X PATCH https://api.rigaly.com/api/v1/rewards/{rewardId} \
  -H "Authorization: Bearer rgly_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"max_redemptions": 50}'
```

Reward payloads report where the ceiling stands:

```jsonc theme={null}
{
  "max_redemptions": 50,
  "redemptions_count": 47,        // completed so far
  "redemptions_remaining": 3,     // null when uncapped — drives "Only 3 left!" in the app
  "deactivated_by_limit": false
}
```

At the ceiling the reward switches itself off — `active: false`, `deactivated_by_limit: true` — and disappears from the customer app. **Raise the cap (or set it to `null`) and it comes back on automatically**; a reward *you* switched off by hand stays off, whatever you do to its cap. Lowering the cap below `redemptions_count` is rejected with `REWARD_LIMIT_BELOW_CURRENT:<count>`.

Cancelling a shipping fulfillment gives its slot back: `redemptions_count` drops, `redemptions_remaining` rises, and a reward the cap had closed reopens on its own — while the customer gets their points back by default. A cancelled order costs you neither the stock nor the goodwill.

<Note>
  In-store codes are handed out *before* the points are spent, so a popular limited reward can be over-claimed: more customers can be holding a pending code than you have stock. Those codes are rejected at the counter with `REDEMPTION_LIMIT_REACHED:<max>`. Shipping and digital rewards don't have this window — they complete on the spot.
</Note>

## Errors

| Message                                 | HTTP | Meaning                                                                                                                            |
| --------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `REWARD_LIMIT_REACHED:<max>`            | 409  | You tried to re-activate a reward that is already at its cap — raise the cap in the same request                                   |
| `REWARD_LIMIT_BELOW_CURRENT:<count>`    | 400  | `max_redemptions` was set below the redemptions already completed                                                                  |
| `REDEMPTION_NOT_IN_STORE:<mode>`        | 400  | You tried to validate or complete a shipping/digital redemption — those complete themselves                                        |
| `REDEMPTION_LIMIT_REACHED:<max>`        | 409  | The code's reward hit its cap after the code was issued; it can't be honored                                                       |
| `REDEMPTION_NOT_SHIPPABLE`              | 400  | Fulfillment update on a redemption that isn't a shipping one                                                                       |
| `FULFILLMENT_ALREADY_CANCELLED`         | 409  | The redemption was already cancelled                                                                                               |
| `Failed to refund redemption points: …` | 400  | The points refund couldn't be paid out. Nothing was applied — no points moved, the status is unchanged — so retry the same request |

Digital provisioning adds these:

| Message                          | HTTP | Meaning                                                                                                                                                                   |
| -------------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DIGITAL_POOL_EXHAUSTED`         | 409  | The pool is empty. On a redemption the reward has just switched itself off; on a patch, you switched the source to `pool` with `active: true` before uploading any values |
| `DIGITAL_PROVISION_FAILED`       | 502  | The external endpoint didn't produce a usable value. The redemption was cancelled and nothing was charged — `POST /rewards/{id}/webhook/test` tells you why               |
| `DIGITAL_WEBHOOK_NOT_CONFIGURED` | 409  | The reward is `external` but has no `digital_webhook_url` or no signing secret                                                                                            |
| `REWARD_NOT_POOL_SOURCED`        | 400  | A pool endpoint was called on a reward whose `digital_asset_source` isn't `pool`                                                                                          |
| `REWARD_NOT_EXTERNALLY_SOURCED`  | 400  | A webhook endpoint was called on a reward whose `digital_asset_source` isn't `external`                                                                                   |
| `POOL_UPLOAD_EMPTY`              | 400  | Every submitted value was blank, over-long, or already stored                                                                                                             |
| `POOL_UPLOAD_TOO_LARGE:<max>`    | 400  | More than 5,000 values in one request — split it                                                                                                                          |
| `POOL_CAPACITY_EXCEEDED:<max>`   | 409  | The upload would push the pool past its 100,000-value ceiling. Nothing was stored                                                                                         |

Full list: [Errors](/errors).
