---
title: Coupons
description: Redeem and look up coupons with POST /api/v1/coupons/redeem and GET /api/v1/coupons/{code}.
---

Coupons are typically granted as an effect of a matched [decide](/docs/decisioning/decide)
rule, then redeemed and checked through these two endpoints.

## Redeem a coupon

```
POST https://api.retidal.com/api/v1/coupons/redeem
```

**Auth**: `X-API-Key` with scope `coupon:redeem`.

**Rate limit**: 30 requests/minute per (project, IP).

Redemption is idempotent per `(profileId, couponId)` unless you supply an
explicit `idempotencyKey` — pass your own order ID or similar if you need to
distinguish separate redemption attempts.

<ParamField name="projectId" in="body" type="string" required>
  Must match the API key's project.
</ParamField>
<ParamField name="code" in="body" type="string" required>
  The coupon code to redeem.
</ParamField>
<ParamField name="profileId" in="body" type="string" required>
  The user/profile redeeming the coupon.
</ParamField>
<ParamField name="idempotencyKey" in="body" type="string">
  Defaults to `<profileId>:<couponId>` when omitted.
</ParamField>
<ParamField name="context" in="body" type="object">
  Optional free-form redemption context.
</ParamField>

```json title="Request"
{
  "projectId": "prj_123",
  "code": "SAVE10",
  "profileId": "uid_456",
  "idempotencyKey": "order-789"
}
```

```json title="200 — accepted (pending settlement)"
{
  "redemptionId": "8f2c…",
  "status": "pending_settlement",
  "benefits": { "discountPercent": 10 }
}
```

`benefits` reflects whatever is configured on the coupon pool — it's an empty
object when the pool defines none.

### Errors

| Status | Meaning |
| --- | --- |
| `400` | Invalid JSON or a required field is missing (`POST /api/v1/coupons/redeem`). |
| `401` | Missing or invalid API key (`POST /api/v1/coupons/redeem`). |
| `403` | The key lacks the `coupon:redeem` scope (`POST /api/v1/coupons/redeem`). |
| `404` | Coupon not found, or cross-tenant (same response shape either way, to prevent enumeration) — `POST /api/v1/coupons/redeem`. |
| `409` | Not redeemable — `already_redeemed`, `expired`, `revoked`, or `pool_not_ready` (`POST /api/v1/coupons/redeem`). |
| `429` | Rate limit exceeded (30/min per project + IP) — `POST /api/v1/coupons/redeem`. |

## Look up a coupon

```
GET https://api.retidal.com/api/v1/coupons/{code}?projectId={projectId}
```

**Auth**: `X-API-Key` with scope `coupon:read`.

Returns sanitized metadata only — no `userId` or `profileId` is included in the
response.

<ParamField name="code" in="path" type="string" required>
  The coupon code to look up.
</ParamField>
<ParamField name="projectId" in="query" type="string" required>
  Must match the API key's project.
</ParamField>

```json title="200"
{
  "status": "redeemed",
  "form": "percentage",
  "poolId": "pool_1",
  "expiresAt": "2026-12-31T23:59:59Z",
  "redeemedAt": "2026-07-20T10:00:00Z"
}
```

`redeemedAt` is present only once the coupon has actually been redeemed.

### Errors

| Status | Meaning |
| --- | --- |
| `401` | Missing or invalid API key (`GET /api/v1/coupons/{code}`). |
| `403` | The key lacks the `coupon:read` scope (`GET /api/v1/coupons/{code}`). |
| `404` | Coupon not found, or cross-tenant (same shape) — `GET /api/v1/coupons/{code}`. |

## Next steps

<CardGroup cols={2}>
  <Card title="Decide" href="/docs/decisioning/decide">
    See how a decision rule can grant a coupon in the first place.
  </Card>
  <Card title="Profiles" href="/docs/decisioning/profiles">
    Keep profile traits current so decision rules evaluate correctly.
  </Card>
</CardGroup>
