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.redeemedfor 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.createdA new coupon code was authored in a project.coupon.updatedA coupon's terms changed, with a field-by-field list of what moved.coupon.activatedA coupon was switched on and is being offered at checkout again.coupon.deactivatedA coupon was switched off and stops being offered at checkout.coupon.deletedA coupon was removed, carrying its final snapshot.coupon.redeemedA subscriber's discounted payment settled and the redemption was recorded.coupon.exhaustedThe last remaining use of a capped coupon was taken.coupon.created
WEBHOOKA 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_atpasses. Schedule againststarts_atfrom 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: falsehere is legitimate, not a race: a creator can author a code ahead of a campaign and switch it on later, which emitscoupon.activated. codeis uppercase regardless of what was typed. A creator enteringblackfridaygetsBLACKFRIDAY. Compare case-sensitively against the uppercase form.- Timestamps inside
data.couponare ISO 8601 with offset (+00:00), unlike the envelope'screated_at, which usesZ. 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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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
WEBHOOKA 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
changescompares the snapshot, not the database columns. It covers exactly the keys indata.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.fromandtokeep their original types. A decimal field gives you decimal strings, an integer field integers, a nullable fieldnull. 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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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
WEBHOOKA 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: trueis one of four conditions. A coupon still will not apply if it is outside itsstarts_at/expires_atwindow, 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_atsimply arrived does not emit this; nothing changed on the row. If you need the moment a code becomes redeemable, schedule againststarts_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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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
WEBHOOKA 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.redeemedto 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_atstops applying but itsactiveflag never changes, so no event fires. If you need the expiry moment, schedule againstexpires_at. - Exhaustion does not emit this either. A coupon that runs out of uses emits
coupon.exhaustedand staysactive: 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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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
WEBHOOKA 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.deactivatedinstead.
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.redeemedrather 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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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
WEBHOOKA 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.redeemedcan 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_amountforward into MRR. redemptions_countis 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_amountis the rounded figure that actually moved, not a raw percentage oforiginal_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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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
WEBHOOKThe 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: nullis 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_countcan exceedmax_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 eventredemptions_countis equal to or greater thanmax_redemptions, never below it. Readredemptions.remainingfrom 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_redemptionsdoes not emit a matching "un-exhausted" event. That is acoupon.updatedwithmax_redemptionsin itschanges. - Expiry is a different thing entirely. A coupon that runs past
expires_atstops 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
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=.
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.
The event name, the same as the envelope's type.
Always application/json.
Always Subscriby-Webhooks/1.0.
Payload
JSONWhat Subscriby posts to your endpointapplication/json
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.
Related
How is this guide?