Version

coupon.* events

Coupon code lifecycle and redemption.

Coupon code lifecycle and redemption. Fires as coupon codes are authored, switched on and off, deleted, and redeemed at checkout. A coupon is one code many subscribers can redeem for money off: the multi-redemption counterpart to an access code, which is one code for one person and grants access outright.

Five of these describe the code itself and are driven by the creator. Two describe money: coupon.redeemed fires once per settled checkout, and coupon.exhausted fires the moment the last remaining use is taken.

Background

The lifecycle events nest a coupon snapshot

created, updated, activated, deactivated and deleted all carry the same nested object under data.coupon, so one consumer can handle all five:

{
  "coupon": {
    "id": "4b9d3e08-a1f6-4275-9c83-7e01d6a2f594",
    "code": "BLACKFRIDAY",
    "name": "Black Friday 2026",
    "discount_type": "percentage",
    "discount_value": "25.0000",
    "currency_id": null,
    "duration": "once",
    "max_redemptions": 500,
    "max_redemptions_per_user": 1,
    "minimum_amount": null,
    "starts_at": "2026-11-27T00:00:00+00:00",
    "expires_at": "2026-12-01T00:00:00+00:00",
    "active": true
  }
}

The snapshot deliberately omits redemption counts. Counts move independently of the coupon row, so a lifecycle event quoting one would be reporting a figure that was already stale by the time the webhook landed. Use coupon.redeemed for counts, or read the current figure from the coupon endpoint.

coupon.redeemed and coupon.exhausted do not use this shape; they are flat, because they describe a transaction rather than the code.

discount_value is a decimal string

It is sent as a string, not a number. On coupon.updated, coupon.activated, coupon.deactivated and coupon.deleted it is read back from the database and carries four decimal places ("25.0000"); on coupon.created it is the value exactly as submitted ("25"), because that payload is built before any round trip. minimum_amount follows the same rule. Parse it as a decimal either way, never a float; money arithmetic on a float is how a $9.99 charge becomes $9.98.

currency_id is null on a percentage coupon and set on a fixed-amount one, because a percentage applies to a plan in any currency and a fixed amount only to plans priced in its own.

An empty plan restriction means every plan

The lifecycle payloads do not carry the plan restriction list. If you need it, read the coupon from the API, and read plan_ids: [] as applies to every plan in the project, including plans added later. It does not mean applies to nothing.

Required ability

Every event in this family requires project-coupon:view at token mint time.

Events

coupon.created

WEBHOOK

A new coupon code was authored in a project.

When this fires

A creator authored a new coupon code over the REST API or via the create_coupon MCP tool. Coupons authored in the dashboard or through the bot do not currently emit this event; those surfaces write through the service layer directly.

It fires on authoring, not on availability. A coupon created with a future starts_at fires this immediately and only becomes redeemable later, so do not read this as "a discount is now live": check starts_at and active.

There is no separate event for a coupon becoming redeemable when its starts_at passes. Schedule against starts_at from this payload if you need that moment.

Caveats

  • The snapshot carries no redemption counts and no plan restriction. Counts move independently of the coupon row, and the plan list is a separate relation. Read both from the coupon endpoint if you need them.
  • A coupon can be created already switched off. active: false here is legitimate, not a race: a creator can author a code ahead of a campaign and switch it on later, which emits coupon.activated.
  • code is uppercase regardless of what was typed. A creator entering blackfriday gets BLACKFRIDAY. Compare case-sensitively against the uppercase form.
  • Timestamps inside data.coupon are ISO 8601 with offset (+00:00), unlike the envelope's created_at, which uses Z. Both are UTC.

Related events

  • coupon.updated: the same snapshot after a change, plus a list of what moved.
  • coupon.activated / coupon.deactivated: availability flips.
  • coupon.redeemed: a subscriber actually used it.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

coupon.updated

WEBHOOK

A coupon's terms changed, with a field-by-field list of what moved.

When this fires

A creator changed a coupon's terms: its code, name, discount, caps, minimum, or window. Fires once per update through the REST API.

The dedicated activate and deactivate endpoints emit coupon.activated / coupon.deactivated rather than this event. An update that includes active is still an update, and emits coupon.updated with active in its changes, so handle active appearing in changes as well as subscribing to the flip events.

Editing terms is dashboard and API only. The bot can create, inspect, switch off and delete a coupon, but not edit its terms; a multi-field edit is a form, not a chat. Dashboard edits do not currently emit this event (the dashboard writes through the service layer directly), so this event originates from the REST API.

changes only contains keys whose value differs. A save that changed nothing sends "changes": {}; the event still fires, because the creator did press save.

Caveats

  • changes compares the snapshot, not the database columns. It covers exactly the keys in data.coupon, so a change to the coupon's plan restriction does not appear; that lives in a separate relation the snapshot deliberately omits. Re-read the coupon endpoint if you track which plans a code covers.
  • from and to keep their original types. A decimal field gives you decimal strings, an integer field integers, a nullable field null. Don't assume both sides are strings.
  • Changing the code does not migrate past redemptions. Redemptions already recorded stay attached to the coupon by id, so a renamed code keeps its history, but any subscriber who wrote the old string down now has a dead code.
  • A change is not retroactive. Subscribers who already redeemed keep the terms they paid under; a reduced discount only affects checkouts from here on.

Related events

  • coupon.created: the same snapshot shape, on authoring.
  • coupon.activated / coupon.deactivated: the on/off flip this event deliberately excludes.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

coupon.activated

WEBHOOK

A coupon was switched on and is being offered at checkout again.

When this fires

A creator switched a coupon on through the activate endpoint. Flipping the switch in the dashboard or through the bot does not currently emit this event; those surfaces write through the service layer directly.

This is the availability flip, kept out of coupon.updated on purpose: an automation that cares whether a discount is live wants one event to subscribe to, not a diff of the active key on every save.

Switched on is not the same as redeemable. active: true is one of four conditions. A coupon still will not apply if it is outside its starts_at / expires_at window, if every use is already spoken for, or if the buyer has hit their own per-subscriber cap. Check the window and the caps in the payload before announcing a live sale.

Caveats

  • This is not scheduled. It fires when a person flips the switch. A coupon whose starts_at simply arrived does not emit this; nothing changed on the row. If you need the moment a code becomes redeemable, schedule against starts_at.
  • Re-activating a switched-off coupon does not reset its counts. Redemptions taken before it was switched off still count against max_redemptions, so a code that was exhausted before is still exhausted after.
  • Idempotent flips still emit. Activating an already-active coupon over the API emits this again. De-dup on the event id.

Related events

  • coupon.deactivated: the paired transition.
  • coupon.updated: a terms change, which deliberately excludes this flip.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

coupon.deactivated

WEBHOOK

A coupon was switched off and stops being offered at checkout.

When this fires

A creator switched a coupon off through the deactivate endpoint. Switching a code off in the dashboard or through the bot does not currently emit this event; those surfaces write through the service layer directly.

This is the safe way to retire a code, and the one Subscriby points creators at when a delete is refused. Switching off stops the code being offered immediately without touching the redemptions already recorded against it.

A checkout already in flight still completes. Switching off blocks new redemptions. A subscriber who already applied the code and is sitting on a payment page still gets their discount; the hold was taken before the switch. Expect a coupon.redeemed to arrive shortly after this event, and do not treat that as an error.

Caveats

  • Expiry does not emit this. A coupon that runs past its expires_at stops applying but its active flag never changes, so no event fires. If you need the expiry moment, schedule against expires_at.
  • Exhaustion does not emit this either. A coupon that runs out of uses emits coupon.exhausted and stays active: true. The two are distinct states: one is a decision, the other an outcome.
  • Nothing is refunded or revoked. Subscribers who already redeemed keep what they paid, and their subscriptions are untouched.

Related events

  • coupon.activated: the paired transition.
  • coupon.exhausted: ran out of uses rather than being switched off.
  • coupon.deleted: the destructive alternative, which stops the code resolving.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

coupon.deleted

WEBHOOK

A coupon was removed, carrying its final snapshot.

When this fires

A creator deleted a coupon through the delete endpoint. Deletions from the dashboard or the bot do not currently emit this event; those surfaces write through the service layer directly.

The payload is a snapshot taken before the delete, so this is your last chance to record what the code was. Read it and store what you need; the code stops resolving at checkout the moment the delete goes through.

The code stops working, the history stays. The coupon row is soft-deleted and its redemption records are left in place; attribution in your own reporting is not lost. What goes is the code itself: it stops resolving at checkout immediately. To retire a code while keeping it visible to creators, use coupon.deactivated instead.

Caveats

  • A delete can be refused. If any subscriber holds a live reservation (a checkout in flight with this discount already quoted to a payment provider) Subscriby refuses the delete and asks the creator to switch the code off instead. No event fires in that case, because nothing happened.
  • The coupon is soft-deleted, not erased. The coupon endpoint currently still resolves it, because the lookup runs unscoped, so do not use a 404 to detect deletion; treat this event as the authoritative signal and store what you need from this payload.
  • No counts. As with every lifecycle event, the snapshot omits redemption counts. If you need a final tally, keep a running total from coupon.redeemed rather than trying to read one at delete time.

Related events

  • coupon.deactivated: the non-destructive way to retire a code.
  • coupon.created: the same snapshot shape, at the other end of the life.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

coupon.redeemed

WEBHOOK

A subscriber's discounted payment settled and the redemption was recorded.

When this fires

A payment that carried a coupon settled. The reservation taken at checkout has been converted into a recorded redemption, and the money has actually moved.

This is the money event in the family. It is the only coupon.* event that carries amounts, and the only one that tells you the code was really used rather than merely offered.

It fires on settlement, not on apply. A subscriber typing a code into checkout does not emit anything. If they abandon the payment, the hold expires and no event ever fires, which is exactly why a coupon.redeemed can be trusted as revenue and a "code applied" signal could not.

This is the amount your reporting should use

final_amount is what the payment provider charged and what Subscriby's own transaction fee is calculated from. If you mirror revenue into a spreadsheet or a warehouse, use final_amount: not the plan's list price, and not original_amount.

Fires alongside payment.succeeded and subscription.activated for the same purchase. All three describe one checkout from three angles; only this one knows a discount was involved.

Caveats

  • Only the first payment is discounted. Coupons are first-payment-only today, so a renewal on the same subscription charges list price and emits no coupon event. Do not carry discount_amount forward into MRR.
  • redemptions_count is read fresh, so it can jump. Under concurrent checkouts two events may report counts that are not consecutive. Treat it as a gauge, not a sequence number, and never derive ordering from it.
  • Amounts are decimal strings in the coupon redemption's currency, which is the plan's currency, not necessarily your dashboard's display currency. Convert before summing across plans.
  • A redemption can arrive after the code was switched off, because a checkout already in flight is allowed to complete. This is expected, not a leak.
  • Percentage and fixed discounts are both rounded to the currency's precision before charging, so discount_amount is the rounded figure that actually moved, not a raw percentage of original_amount.

Related events

  • coupon.exhausted: emitted immediately after this one when the redemption takes the last use.
  • payment.succeeded: the payment itself.
  • subscription.activated: the subscription it bought.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

coupon.exhausted

WEBHOOK

The last remaining use of a capped coupon was taken.

When this fires

A settled redemption took the last remaining use of a capped coupon. Fires immediately after the coupon.redeemed that consumed it, in the same emission.

Use it to close a campaign automatically: pull the code out of a landing page, stop an ad, post "sold out", or notify the creator that the cap they set has been reached.

Only capped coupons can fire this. A coupon with max_redemptions: null is unlimited and never emits this event.

Exhaustion counts live reservations, so it can un-exhaust

This is the one behaviour that surprises people. The quota is enforced against settled redemptions plus reservations whose hold has not expired, so a coupon can report exhausted while the last few uses are still only held by checkouts in progress.

If one of those checkouts is abandoned, the hold expires and its use returns to the pool. The coupon becomes redeemable again, and no event fires for that, because nothing changed on the coupon row.

That design is deliberate: it means an abandoned checkout never permanently burns a use, and no sweeper job is needed to reclaim one. The consequence for you is that coupon.exhausted is a strong signal but not a permanent state. If your automation does something hard to undo (pausing an ad spend, emailing a list) verify the current state with the coupon endpoint first, and read redemptions.remaining rather than comparing counts yourself.

redemptions_count can exceed max_redemptions. The counter is bumped when a checkout takes a hold, not when it settles, and is only decremented when a hold is explicitly released. Lapsed holds the stale-reservation sweep has not yet reclaimed still sit in it, so on this event redemptions_count is equal to or greater than max_redemptions, never below it. Read redemptions.remaining from the API if you need the reservation-aware figure.

Caveats

  • It can fire more than once for the same coupon. Exhausted → a hold expires → redeemable → exhausted again is a legitimate sequence. De-dup on the event id, not on the coupon id.
  • A creator raising max_redemptions does not emit a matching "un-exhausted" event. That is a coupon.updated with max_redemptions in its changes.
  • Expiry is a different thing entirely. A coupon that runs past expires_at stops applying without ever being exhausted, and emits nothing.

Related events

  • coupon.redeemed: the redemption that consumed the last use; always precedes this.
  • coupon.updated: where a raised cap shows up.
  • coupon.deactivated: switched off by decision rather than run out by use.

Ability to subscribe

A token needs this to subscribe an endpoint to the event.

Header Parameters

SB-Signature*string

t=<unix seconds>,v1=<hex>: the HMAC-SHA256 of "<t>.<raw body>" under the endpoint's secret. Verify it before acting, and refuse a t more than 300 seconds from now. During a secret rotation a v0= signature under the previous secret may precede v1=.

SB-Event-Id*string

The event's ULID, bare. The envelope's id is the same ULID prefixed evt_, so strip the prefix before comparing. Deduplicate on it: a retry carries the same id.

SB-Event-Name*string

The event name, the same as the envelope's type.

Content-Type*string

Always application/json.

User-Agent*string

Always Subscriby-Webhooks/1.0.

Payload

JSONWhat Subscriby posts to your endpoint

The signed JSON envelope posted to your endpoint.

The envelope every event is delivered in.

Responses

2XXAny success status

Your endpoint acknowledged the delivery. Any 2xx status within 30 seconds marks it delivered; the response body is ignored.

defaultAny other status

Any other status, a connection failure, or no answer within 30 seconds counts as a failed attempt. The delivery is retried 8 times, after 10 seconds, 30 seconds, 2 minutes, 10 minutes, 1 hour, 6 hours, 1 day, 3 days; the last failure dead-letters it, and it can be retried from the dashboard or POST /v1/webhook-deliveries/{delivery}/retry. After 20 consecutive failures the endpoint is paused until it is resumed.

How is this guide?

Version

On this page

Subscriby is a product
designed by you — for you.
No boardroom full of executives deciding what we ships next. Our roadmap always shaped by you with your feedback.

Share feedback or a request