Coupon Mutations
Coupon mutations let you create and edit discount coupons in your school, and hand a restricted coupon to individual members.
A normal coupon can be redeemed by anyone who knows its code. A restricted coupon is grant-only: only members you grant it to with createCouponGrant can redeem it, and each can redeem it once, at checkout, while signed in.
Endpoint and authentication. All admin mutations are sent to
POST /admin/graphql. Authenticate with ansk_live_…API key in theX-Teachify-API-Keyheader. See the API Overview for details.
Available Mutations
Section titled “Available Mutations”| Mutation | Purpose |
|---|---|
createCoupon | Create a coupon. Set restricted: true for a grant-only coupon. |
updateCoupon | Partially update a coupon. |
createCouponGrant | Grant a restricted coupon to an existing member. |
revokeCouponGrant | Revoke a member’s grant so it can no longer be redeemed. |
Required scope
Section titled “Required scope”All four mutations require the coupons:write scope.
Finding IDs
Section titled “Finding IDs”Coupon, grant and member IDs are UUIDs.
- Coupon ID (
couponIdincreateCouponGrant,idinupdateCoupon): theidthatcreateCouponor thecouponsquery returns. In the school admin, it is also the last segment of the coupon’s page URL. - Member ID (
userId): thestudent.idthatcreateStudentreturns. You can pass the member’semailinstead. - Grant ID (
grantId): thegrant.idthatcreateCouponGrantreturns, or anidfrom thecouponGrantsquery. To find one member’s grant, filter that query byuserEmailoruserId.
Typical flow: grant-only coupons
Section titled “Typical flow: grant-only coupons”- Create the coupon with
createCouponandrestricted: true. - Make sure each recipient is already a member of the school. If not, create them first with
createStudent. It sends no email to the member and is safe to retry: an email that already belongs to a member returns that member. If the account it returns has been deleted, or belongs to another school,createCouponGrantrejects it withENROLLMENT-002. - Call
createCouponGrantonce per member. - Optionally call
revokeCouponGrantto withdraw a grant that has not been used. If you did not keep the grant ID, find it withcouponGrantsfiltered by the member’suserEmailoruserId; that query needs thecoupons:readandstudents:readscopes.
Members are not notified when you grant them a coupon: no email, in-app message, or webhook is sent. If members need to know about it, notify them yourself.
Tracking redemptions
Section titled “Tracking redemptions”To learn when a member uses a grant, subscribe to the payment.paid webhook. Its coupon object carries the code the order used, and its user identifies the member.
Match on coupon.code, not coupon.id. The id in that payload belongs to the coupon’s discount code, not to the coupon that createCoupon returns. If the school also generated extra discount codes for the coupon in the admin, an order can carry one of those codes instead.
You can also read a grant’s status through the couponGrants query.
Rate limits
Section titled “Rate limits”createCouponGrant grants one member per call and counts against the Admin API mutation limit. See Rate Limiting. A throttled response carries a Retry-After header; wait that long before retrying.
Error reporting
Section titled “Error reporting”These mutations report failures in two ways:
- Coded errors (for example a coupon that cannot be found, or a member that does not exist in the school) are raised into the standard GraphQL
errorsarray. The machine-readable code is inextensions.error.code. See Error codes. - Model validation failures are returned in the payload’s
errorsfield as an array of human-readable messages, with the main field (couponorgrant) set tonull.
createCoupon
Section titled “createCoupon”Creates a discount coupon in your school.
createCoupon(input: AdminCouponInput!): AdminCouponCreatePayload!
type AdminCouponCreatePayload { coupon: AdminCoupon # The created coupon. null when errors is present. errors: [String!] # Model validation messages, or null on success.}AdminCouponInput
Section titled “AdminCouponInput”| Field | Type | Required | Description |
|---|---|---|---|
name | String! | Yes | Internal coupon name shown in the admin list. |
couponType | String! | Yes | Discount type: fixed_amount or percentage. Any other value raises COUPON-002. |
amount | Float! | Yes | Discount value. For percentage it must be greater than 0 and at most 100; for fixed_amount it is an amount in the school currency. |
code | String | No | Public coupon code. All whitespace is stripped. Must be unique among the school’s existing (non-deleted) coupons and discount codes; deactivated ones still hold their code. A readable 6-character code is generated when omitted. |
description | String | No | Internal note about the coupon. |
active | Boolean | No | Whether the coupon can be redeemed. Defaults to true. |
startedAt | BigInt | No | Unix timestamp when the coupon becomes redeemable. Unlimited when omitted. |
expiredAt | BigInt | No | Unix timestamp when the coupon stops being redeemable. Never expires when omitted. Must be after startedAt. |
redemptionLimit | Int | No | Maximum number of redemptions. Unlimited when omitted. Ignored for restricted coupons: each grant is its own single use, so the number of grants is the cap. |
oncePerUser | Boolean | No | Whether each member may redeem the coupon only once. Defaults to false. |
restricted | Boolean | No | Grant-only coupon: only members granted it through createCouponGrant can redeem it, each once. Defaults to false. Cannot be changed after creation. |
itemIds | [String!] | No | IDs of the products the coupon applies to (courses, curriculum plans, events, tickets, digital products, membership plans). Applies to everything when omitted or empty. IDs that are not in this school’s selectable catalog are ignored. |
restricted can only be set here. updateCoupon has no restricted field, so a coupon stays restricted or unrestricted for its whole life.
Example
Section titled “Example”mutation { createCoupon( input: { name: "Alumni thank-you" code: "ALUMNI2026" couponType: "percentage" amount: 20 restricted: true startedAt: 1793491200 expiredAt: 1798732799 } ) { coupon { id name code couponType amount active restricted oncePerUser redemptionLimit startedAt expiredAt appliedCount } errors }}{ "data": { "createCoupon": { "coupon": { "id": "4f0c2a9e-7b1d-4c3e-9a52-8d6e1f3b7c20", "name": "Alumni thank-you", "code": "ALUMNI2026", "couponType": "percentage", "amount": 20.0, "active": true, "restricted": true, "oncePerUser": false, "redemptionLimit": null, "startedAt": 1793491200, "expiredAt": 1798732799, "appliedCount": 0 }, "errors": null } }}updateCoupon
Section titled “updateCoupon”Updates an existing coupon. This is a partial update: only the fields present in input are changed, and omitted fields keep their current values.
updateCoupon( id: String! # Required. ID of the coupon to update. input: AdminCouponUpdateInput!): AdminCouponUpdatePayload!
type AdminCouponUpdatePayload { coupon: AdminCoupon # The updated coupon. null when errors is present. errors: [String!] # Model validation messages, or null on success.}The coupon code and the restricted flag cannot be changed.
AdminCouponUpdateInput
Section titled “AdminCouponUpdateInput”| Field | Type | Required | Description |
|---|---|---|---|
name | String | No | Internal coupon name shown in the admin list. |
couponType | String | No | Discount type: fixed_amount or percentage. Send a matching amount along with a type change, otherwise the current amount may be invalid for the new type. Any other value raises COUPON-002. |
amount | Float | No | Discount value. For percentage it must be greater than 0 and at most 100; for fixed_amount it is an amount in the school currency. |
description | String | No | Internal note about the coupon. Pass null to clear it. |
active | Boolean | No | Whether the coupon can be redeemed. Pass false to deactivate the coupon. An explicit null is ignored. |
startedAt | BigInt | No | Unix timestamp when the coupon becomes redeemable. Pass null to clear the start date. |
expiredAt | BigInt | No | Unix timestamp when the coupon stops being redeemable. Pass null to clear the expiry. Must be after startedAt. |
redemptionLimit | Int | No | Maximum number of redemptions. Pass null to make it unlimited. Not enforced for restricted coupons. |
oncePerUser | Boolean | No | Whether each member may redeem the coupon only once. An explicit null is ignored. |
itemIds | [String!] | No | IDs of the products the coupon applies to. Replaces the current selection; pass an empty array to make the coupon apply to everything. Omitting the field or passing null leaves the current selection unchanged. IDs that are not in this school’s selectable catalog are ignored, except IDs the coupon already holds, which are kept. |
Example
Section titled “Example”Extend the expiry to 2027-02-01 00:00 UTC and remove the redemption cap:
mutation { updateCoupon( id: "4f0c2a9e-7b1d-4c3e-9a52-8d6e1f3b7c20" input: { expiredAt: 1801440000, redemptionLimit: null } ) { coupon { id code expiredAt redemptionLimit restricted } errors }}{ "data": { "updateCoupon": { "coupon": { "id": "4f0c2a9e-7b1d-4c3e-9a52-8d6e1f3b7c20", "code": "ALUMNI2026", "expiredAt": 1801440000, "redemptionLimit": null, "restricted": true }, "errors": null } }}createCouponGrant
Section titled “createCouponGrant”Grants a restricted coupon to an existing member of your school. The member can then redeem it once at checkout while signed in. Restricted coupons cannot be redeemed by anyone who has no grant.
createCouponGrant( couponId: String! # Required. ID of a restricted coupon. userId: String # ID of the member. Mutually exclusive with email. email: String # Email of the member, resolved within this school. Mutually exclusive with userId.): AdminCouponGrantCreatePayload!
type AdminCouponGrantCreatePayload { grant: AdminCouponGrant # The member's grant. null when errors is present. created: Boolean # false when the member already held an active grant for this coupon. errors: [String!] # Messages when the grant could not be created, or null on success.}Identifying the member
Section titled “Identifying the member”Provide exactly one of userId or email:
- Neither provided: raises
COUPON-005. - Both provided: raises
COUPON-006. emailis matched case-insensitively within your school.- Only existing, non-deleted members of your school can be granted a coupon. An unknown or unregistered email, or the ID of a user from another school, raises
ENROLLMENT-002. No user is created: create the member first withcreateStudent.
The coupon must exist in your school (COUPON-001) and must be restricted (COUPON-003).
The coupon’s own state is not checked. Granting a coupon that has expired or been deactivated still succeeds, but the member cannot redeem it while the coupon stays that way. A grant on an expired coupon reports status: "expired"; a grant on a deactivated coupon still reports granted. Reactivating the coupon or extending its expiry makes those grants redeemable again. Check active and expiredAt on the coupon before granting it.
Idempotency
Section titled “Idempotency”Granting the same coupon to the same member again does not create a second grant. The existing grant is returned with created: false, so it is safe to retry or to re-run an import. This includes a grant the member already redeemed: it comes back with created: false and status: "redeemed", and the member cannot use it again.
A revoked grant no longer counts. Granting again after a revoke creates a new grant with created: true, which the member can redeem once more, even if they redeemed the revoked one.
Example
Section titled “Example”mutation { createCouponGrant( couponId: "4f0c2a9e-7b1d-4c3e-9a52-8d6e1f3b7c20" email: "alice@example.com" ) { created grant { id status userName userEmail grantedAt redeemedAt revokedAt } errors }}{ "data": { "createCouponGrant": { "created": true, "grant": { "id": "b81e5d47-2c9a-4f06-8e3b-5a7d9c1e2f64", "status": "granted", "userName": "Alice Chen", "userEmail": "alice@example.com", "grantedAt": 1791972000, "redeemedAt": null, "revokedAt": null }, "errors": null } }}Repeating the same call returns the same grant with "created": false.
AdminCouponGrant
Section titled “AdminCouponGrant”| Field | Type | Description |
|---|---|---|
id | ID! | Unique identifier of the grant. Pass it to revokeCouponGrant. |
status | String! | One of granted, redeemed, revoked, expired. See below. |
userName | String | Name of the member the coupon is granted to. |
userEmail | String | Email of the member the coupon is granted to. |
grantedByName | String | Name of the user who granted the coupon. null when the grant was made with an API key. |
grantedAt | Int! | Unix timestamp when the coupon was granted. |
redeemedAt | Int | Unix timestamp when the member redeemed the coupon, or null. |
revokedAt | Int | Unix timestamp when the grant was revoked, or null. |
status values:
| Value | Meaning |
|---|---|
granted | The member holds the coupon and has not used it. |
redeemed | The member has redeemed the coupon. |
revoked | The grant was revoked. |
expired | The grant was not redeemed and the coupon itself has expired. |
The grants of a coupon are also available through the couponGrants query, paginated and filterable by member, or all at once through the grants field of AdminCoupon.
revokeCouponGrant
Section titled “revokeCouponGrant”Revokes a member’s coupon grant so it can no longer be redeemed.
revokeCouponGrant( grantId: String! # Required. ID of an active (not revoked) coupon grant.): AdminCouponGrantRevokePayload!
type AdminCouponGrantRevokePayload { grant: AdminCouponGrant # The revoked grant, with status "revoked". errors: [String!]}- The grant must belong to your school and must not already be revoked. Otherwise the mutation raises
COUPON-004. - Revoking a grant that was already redeemed does not undo the redemption.
- The member can be granted the coupon again after a revoke by calling
createCouponGrant. The new grant can be redeemed once more.
Example
Section titled “Example”mutation { revokeCouponGrant(grantId: "b81e5d47-2c9a-4f06-8e3b-5a7d9c1e2f64") { grant { id status revokedAt } errors }}{ "data": { "revokeCouponGrant": { "grant": { "id": "b81e5d47-2c9a-4f06-8e3b-5a7d9c1e2f64", "status": "revoked", "revokedAt": 1792034460 }, "errors": null } }}Error codes
Section titled “Error codes”| Code | Mutation | Meaning |
|---|---|---|
COUPON-001 | updateCoupon, createCouponGrant | Coupon not found (not in this school, or deleted). |
COUPON-002 | createCoupon, updateCoupon | Invalid coupon type. Valid values are fixed_amount and percentage. |
COUPON-003 | createCouponGrant | Only restricted coupons can be granted to members. |
COUPON-004 | revokeCouponGrant | Coupon grant not found, or already revoked. |
COUPON-005 | createCouponGrant | Neither userId nor email was provided. |
COUPON-006 | createCouponGrant | Both userId and email were provided; they are mutually exclusive. |
ENROLLMENT-002 | createCouponGrant | Member not found in this school. |
GENERAL-001 | all | An unexpected error occurred while processing the request. |
Coded errors are returned in the standard GraphQL errors array, with the code in extensions.error.code:
{ "errors": [ { "message": "Only restricted coupons can be granted to members", "path": ["createCouponGrant"], "extensions": { "error": { "code": "COUPON-003", "message": "Only restricted coupons can be granted to members", "details": {} } } } ], "data": null}Related Resources
Section titled “Related Resources”For more information about the Teachify Admin API, please refer to the API Overview.