Skip to content

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 an sk_live_… API key in the X-Teachify-API-Key header. See the API Overview for details.

MutationPurpose
createCouponCreate a coupon. Set restricted: true for a grant-only coupon.
updateCouponPartially update a coupon.
createCouponGrantGrant a restricted coupon to an existing member.
revokeCouponGrantRevoke a member’s grant so it can no longer be redeemed.

All four mutations require the coupons:write scope.

Coupon, grant and member IDs are UUIDs.

  • Coupon ID (couponId in createCouponGrant, id in updateCoupon): the id that createCoupon or the coupons query returns. In the school admin, it is also the last segment of the coupon’s page URL.
  • Member ID (userId): the student.id that createStudent returns. You can pass the member’s email instead.
  • Grant ID (grantId): the grant.id that createCouponGrant returns, or an id from the couponGrants query. To find one member’s grant, filter that query by userEmail or userId.
  1. Create the coupon with createCoupon and restricted: true.
  2. 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, createCouponGrant rejects it with ENROLLMENT-002.
  3. Call createCouponGrant once per member.
  4. Optionally call revokeCouponGrant to withdraw a grant that has not been used. If you did not keep the grant ID, find it with couponGrants filtered by the member’s userEmail or userId; that query needs the coupons:read and students:read scopes.

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.

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.

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.

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 errors array. The machine-readable code is in extensions.error.code. See Error codes.
  • Model validation failures are returned in the payload’s errors field as an array of human-readable messages, with the main field (coupon or grant) set to null.

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.
}
FieldTypeRequiredDescription
nameString!YesInternal coupon name shown in the admin list.
couponTypeString!YesDiscount type: fixed_amount or percentage. Any other value raises COUPON-002.
amountFloat!YesDiscount value. For percentage it must be greater than 0 and at most 100; for fixed_amount it is an amount in the school currency.
codeStringNoPublic 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.
descriptionStringNoInternal note about the coupon.
activeBooleanNoWhether the coupon can be redeemed. Defaults to true.
startedAtBigIntNoUnix timestamp when the coupon becomes redeemable. Unlimited when omitted.
expiredAtBigIntNoUnix timestamp when the coupon stops being redeemable. Never expires when omitted. Must be after startedAt.
redemptionLimitIntNoMaximum 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.
oncePerUserBooleanNoWhether each member may redeem the coupon only once. Defaults to false.
restrictedBooleanNoGrant-only coupon: only members granted it through createCouponGrant can redeem it, each once. Defaults to false. Cannot be changed after creation.
itemIds[String!]NoIDs 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.

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
}
}
}

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.

FieldTypeRequiredDescription
nameStringNoInternal coupon name shown in the admin list.
couponTypeStringNoDiscount 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.
amountFloatNoDiscount value. For percentage it must be greater than 0 and at most 100; for fixed_amount it is an amount in the school currency.
descriptionStringNoInternal note about the coupon. Pass null to clear it.
activeBooleanNoWhether the coupon can be redeemed. Pass false to deactivate the coupon. An explicit null is ignored.
startedAtBigIntNoUnix timestamp when the coupon becomes redeemable. Pass null to clear the start date.
expiredAtBigIntNoUnix timestamp when the coupon stops being redeemable. Pass null to clear the expiry. Must be after startedAt.
redemptionLimitIntNoMaximum number of redemptions. Pass null to make it unlimited. Not enforced for restricted coupons.
oncePerUserBooleanNoWhether each member may redeem the coupon only once. An explicit null is ignored.
itemIds[String!]NoIDs 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.

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
}
}
}

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.
}

Provide exactly one of userId or email:

  • Neither provided: raises COUPON-005.
  • Both provided: raises COUPON-006.
  • email is 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 with createStudent.

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.

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.

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.

FieldTypeDescription
idID!Unique identifier of the grant. Pass it to revokeCouponGrant.
statusString!One of granted, redeemed, revoked, expired. See below.
userNameStringName of the member the coupon is granted to.
userEmailStringEmail of the member the coupon is granted to.
grantedByNameStringName of the user who granted the coupon. null when the grant was made with an API key.
grantedAtInt!Unix timestamp when the coupon was granted.
redeemedAtIntUnix timestamp when the member redeemed the coupon, or null.
revokedAtIntUnix timestamp when the grant was revoked, or null.

status values:

ValueMeaning
grantedThe member holds the coupon and has not used it.
redeemedThe member has redeemed the coupon.
revokedThe grant was revoked.
expiredThe 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.

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.
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
}
}
}
CodeMutationMeaning
COUPON-001updateCoupon, createCouponGrantCoupon not found (not in this school, or deleted).
COUPON-002createCoupon, updateCouponInvalid coupon type. Valid values are fixed_amount and percentage.
COUPON-003createCouponGrantOnly restricted coupons can be granted to members.
COUPON-004revokeCouponGrantCoupon grant not found, or already revoked.
COUPON-005createCouponGrantNeither userId nor email was provided.
COUPON-006createCouponGrantBoth userId and email were provided; they are mutually exclusive.
ENROLLMENT-002createCouponGrantMember not found in this school.
GENERAL-001allAn 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
}

For more information about the Teachify Admin API, please refer to the API Overview.