Get Started
issy Marketing Cloud provides a collection of API endpoints that allows developers to integrate Loyalty Program functionalities into their applications. The functionalities available are:
- List of Loyalty Programs.
- Participate on a Loyalty Program enabling users to acumulate credits based on a purchase receipt.
- List the rewards related to a Loyalty Programs.
- Transfer credits between users.
- Redeem credits for rewards.
API appKey and appToken
Request to the issy service provider how to obtain an appKey and appToken. API appKey and appToken should be kept secured and should not be uploaded into a code repository. If a Secret is believed to be compromised, it can be disabled in the portal and a new Secret can be issued.
Urls
Dev enviroment
https://api-dev.issy.app
Prod enviroment
https://api.issy.app
Authentication
issy Marketing Cloud offers authentication by an API appToken. Use the appToken together with the tenant id to authenticate API requests by sending their values in these HTTP headers:
Headers
{
"issy-token": "{account app token}",
"issy-tenant-id": "{tenant id}",
}
All requests must contain an issy-token and issy-tenant-id in header.
If you are integrating a single tenant, your issy-tenant-id is fixed and provided to you by the issy service provider, so it can be hardcoded in your integration.
Multitenant Authentication
If your application needs to serve more than one tenant with the same issy-token (for example, a hub or portal that lists multiple brands), your appToken is issued as a multitenant application token. In that case you won't have a fixed issy-tenant-id beforehand — you'll only know each tenant's public username/slug (the same slug used in the tenant's storefront URL, e.g. issy.app/{tenant username}/...). Use the Resolve Tenant endpoint to translate that username into the issy-tenant-id you must send on every subsequent request.
Resolve Tenant
Validates the appToken and resolves a tenant username/slug to its issy-tenant-id, also returning basic branding info for that tenant.
GET /resolvetenant
POST /resolvetenant
The tenant username can be sent as a header, a query string parameter, or (POST only) in the JSON body — in that order of precedence.
Headers
{
"issy-token": "{account app token}",
"issy-tenant-username": "{tenant username/slug}"
}
REQUEST (alternative, POST body)
{
"tenantUsername": "{tenant username/slug}"
}
RESPONSE
{
"tenantId": "{tenant id, use this value as issy-tenant-id in subsequent requests}",
"username": "{tenant username/slug}",
"name": "{tenant name}",
"logoUrl": "{tenant logo url}",
"previewImgUrl": "{tenant preview image / favicon url}",
"headerColor": "{tenant header color}",
"primaryColor": "{tenant primary color}",
"specificTermsAndConditions": "{true/false, never null - which Terms & Conditions mode this tenant uses. See Terms & Conditions Acceptance below}"
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 401 | MISSING_ISSY_TOKEN | The issy-token header was not sent. |
| 401 | INVALID_TOKEN | The issy-token doesn't match any active application token. |
| 400 | MISSING_TENANT_USERNAME | No tenant username in header, query, or body. |
| 404 | TENANT_NOT_FOUND | No tenant matches that username/slug. |
| 403 | TENANT_NOT_ALLOWED | The issy-token is valid but not authorized for that tenant. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
error response example
{
"success": false,
"errorCode": "TENANT_NOT_FOUND",
"message": "Tenant not found."
}
User identifier
How UserId is resolved depends on who manages the promotion's user lifecycle — see Terms & Conditions Acceptance below for the same Issy/Client distinction.
- Client-managed promotions:
UserIdcan be any identifier you choose — an anonymous id you generate before the user registers, their email, or their phone number. The API matches whichever value you send against email, phone, or account id. - Issy-managed promotions: loyalty participation and redemption endpoints require the account
idreturned by Account Authentication, plus an active session — see User Session below.
Error Format
issy API responses use standard HTTP status codes. On top of the status code, every error response from an endpoint returns a JSON body describing what went wrong, so your application can branch on errorCode instead of parsing the message text:
{
"success": false,
"errorCode": "{machine-readable error code, e.g. PROMOTION_NOT_FOUND}",
"message": "{human-readable description}"
}
Some errors include extra fields alongside errorCode/message with additional detail. For example, when Terms & Conditions Acceptance is required and missing:
{
"success": false,
"errorCode": "TERMS_NOT_ACCEPTED",
"message": "The user must accept the Terms & Conditions / Privacy Policy before continuing.",
"requiredAcceptances": ["AcceptedTermsAndConditions", "BrandOwnerOptin"]
}
- 2xx: Indicates success
- 4xx: Client/authentication error — check
errorCodefor the specific reason - 5xx: Server error (
errorCodeisINTERNAL_ERRORunless noted otherwise)
errorCode values used across the API — endpoints only return the subset relevant to what they validate; each endpoint's section lists exactly which ones apply:
| errorCode | HTTP | Meaning |
|---|---|---|
INVALID_HEADER_CONTENT | 401 / 406 | Missing/invalid issy-token or issy-tenant-id. |
PROMOTION_NOT_FOUND | 404 | PromotionId doesn't match a promotion for this tenant. |
USER_NOT_FOUND | 404 | The given UserId doesn't match a registered user — see User identifier for what UserId can be. |
PARTICIPATION_NOT_FOUND | 404 | PromotionLocationUserId doesn't match an existing participation. |
PROMOTION_FORM_NOT_FOUND / FORM_ITEM_NOT_FOUND | 404 | The promotion has no form, or an answered field isn't part of it. |
PROFILE_NOT_FOUND / USER_PROFILE_NOT_FOUND | 404 | The user has no participation profile for that promotion yet. |
LOYALTY_PROGRAM_NOT_FOUND | 404 | The reward doesn't belong to the given Loyalty Program. |
PROMOTION_INVALID_FOR_ACTION | 406 | The promotion's configuration doesn't support this action (e.g. calling begin-without-POP on a promotion that requires POP). |
INSUFFICIENT_CREDITS | 406 | Not enough credits available for the redemption/transfer. |
REDEMPTIONS_UNAVAILABLE | 451 | The reward's redemption limit has been reached. |
INVALID_PARTICIPATION | 412 | The participation doesn't belong to this user, or is already completed. |
INVALID_PARTICIPATION_ANSWERS | 412 | Fewer form answers than the promotion's minimum. |
INVALID_USERS_PARAMS | 412 | Sender and receiver are the same user (credit transfer). |
POP_VALIDATION_FAILED | varies (300/406/451/500) | Proof-of-purchase image failed validation; see Begin participation WITH proof of purchase. |
POP_CODE_REQUIRED / POP_CODE_INVALID / POP_CODE_CREDITS_MISSING | 406 | POP code missing, invalid/used, or has no credits configured; see Create loyalty participation with POP code. |
IMAGE_REQUIRED | 406 | ImageBase64/ImageExtension missing; see Create loyalty participation with photo. |
PHOTO_VALIDATION_FAILED | 400 | Photo couldn't be evaluated (unrelated to the invoice-flow POP_VALIDATION_FAILED above); see Create loyalty participation with photo. |
PHOTO_DUPLICATED | 409 | This exact photo was already used in a previous participation; see Create loyalty participation with photo. |
TERMS_NOT_ACCEPTED | 412 | Terms & Conditions / opt-ins required and missing. requiredAcceptances lists which of AcceptedTermsAndConditions/BrandOwnerOptin are missing. |
TERMS_FIELD_NOT_APPLICABLE | 400 | Sent BrandOwnerOptin while the tenant is in simple mode, or MarketingOptIn while in specific mode. |
INVALID_DATE_OF_BIRTH | 400 | DateOfBirth isn't a real past date in yyyy-MM-dd format, and it would be stored (new account, or one without a birth date yet); see Age Gate. |
BIRTHDATE_REQUIRED | 406 | The promotion/loyalty program has the age gate enabled and the user has no birth date available; see Age Gate. |
AGE_REQUIREMENT_NOT_MET | 406 | The promotion/loyalty program has the age gate enabled and the user is younger than minimumAge; see Age Gate. |
USER_AND_PROMOTION_REQUIRED | 400 | UserId/PromotionId missing on POST /termsacceptance. |
EMAIL_PASSWORD_REQUIRED | 400 | Missing Email/Password on an auth endpoint. |
USER_ALREADY_EXISTS | 400 | POST /auth/register — a user with that email already exists. |
INVALID_LOGIN_ATTEMPT | 401 | POST /auth/login — wrong email/password. |
USER_CREATION_FAILED | 400 | ASP.NET Identity rejected the new account (e.g. weak password); message has the details. |
INVALID_GOOGLE_TOKEN / INVALID_FACEBOOK_TOKEN | 401 | The social login token is invalid or expired. |
GOOGLE_EMAIL_MISSING / FACEBOOK_EMAIL_MISSING | 400 | The social account didn't expose an email. |
ID_TOKEN_REQUIRED / ACCESS_TOKEN_REQUIRED | 400 | Missing token in the Google/Facebook login request. |
TENANT_NOT_FOUND / MISSING_TENANT_USERNAME / MISSING_ISSY_TOKEN / INVALID_TOKEN / TENANT_NOT_ALLOWED | varies | See Resolve Tenant. |
INVALID_PARAMS | 401 | redeemreward — user or user profile not found. |
LOGIN_REQUIRED | 401 | The promotion is issy-managed and no SessionId was sent; see User Session. |
INVALID_SESSION | 401 | SessionId doesn't match an active session, is expired, or belongs to a different UserId. |
EMAIL_OR_PHONE_REQUIRED | 400 | POST /auth/register/request-code or POST /auth/login/request-code — neither or both of Email/PhoneNumber were sent. |
ALREADY_REGISTERED | 400 | POST /auth/register/request-code — that email/phone already has a full account; use Login instead. |
TOO_MANY_REQUESTS | 429 | Rate limit exceeded on a code-request endpoint (max 3 / 10 minutes, 5 / hour per identifier). |
CODE_NOT_FOUND | 404 | TokenId doesn't match a pending verification code. |
CODE_INVALID | 401 | The verification code doesn't match. |
CODE_EXPIRED | 401 | The verification code expired (10 minutes after it was sent). |
CODE_ALREADY_USED | 401 | The verification code was already used to complete a registration/login. |
INTERNAL_ERROR | 500 | Unexpected server error. |
Terms & Conditions Acceptance
This is the most involved concept in the API — read this section fully before implementing registration or participation. It mirrors the same T&C/Privacy Policy logic issy's own WebFrontEnd uses, exposed over the API.
There are two independent settings that determine what your frontend needs to send. Both are read from the promotion/tenant data you already fetch (Promotions, Loyalty Programs, Resolve Tenant) — you never hardcode them.
Setting 1 — does the API manage this promotion's consent at all?
Every Promotion / Loyalty Program is configured with who manages the API consumer relationship:
- Issy (default): issy enforces and records the user's Terms & Conditions / Privacy Policy acceptance via this API.
- Client: the promotion is managed by a third-party integrator's own consent flow — the API does not gate on it at all.
This is exposed as a boolean, always true/false (never null), but the JSON key's casing differs depending on which endpoint you call — this is a real, deliberate inconsistency in the API kept for backwards compatibility, not a typo in this doc:
| Endpoints | Field name in response |
|---|---|
Detail: GET /promotions/{id}, GET /loyaltyprograms/{id}, GET /rewards/{promotionid}/{id} | "APIRequireIssyTermsAcceptance" (PascalCase) |
List: GET /promotions, GET /loyaltyprograms, GET /rewards/{promotionid} | "apiRequireIssyTermsAcceptance" (camelCase) |
true = issy manages it (gate applies, see below). false = client-managed (nothing below applies for that promotion — the gate is skipped entirely, and GET/POST /termsacceptance become no-ops that always report "not required").
Setting 2 — if issy manages it, what shape does consent take?
When a promotion is issy-managed, the shape of what you must send depends on the tenant's mode, returned as specificTermsAndConditions by Resolve Tenant (always true/false, never null):
Simple mode (specificTermsAndConditions: false, default)
- Only
AcceptedTermsAndConditionsmatters (plus optionalMarketingOptIn). - There is no persisted "already accepted" state in this mode — same as WebFrontEnd's generic forms, which re-ask on every submit. You must send
AcceptedTermsAndConditions: trueon every request that enforces the gate; sendingfalseor omitting it always fails withTERMS_NOT_ACCEPTED, even if the user accepted on a previous call. - On success,
MarketingOptIn(if sent) andNotificationsOptIn(alwaystrue) are written directly to the user's account — never to a per-promotion permission record. - Sending
BrandOwnerOptinin this mode is rejected with400 TERMS_FIELD_NOT_APPLICABLE.
Specific mode (specificTermsAndConditions: true)
AcceptedTermsAndConditionsis the promotion-level opt-in. Unlike simple mode, this is persisted per user+promotion — once accepted, later requests don't need to resend it.BrandOwnerOptinis the marketing opt-in with the tenant's brand owner (e.g. "I agree to be contacted by Hennessy"). It is optional for the user —falseis a valid answer — but it must be answered (trueorfalse) once: while the tenant has a brand owner and the user has never answered, the gate listsBrandOwnerOptininrequiredAcceptancesif you omit it. Once atrueorfalseis stored, it's never required again. Always send the checkbox value, checked or not.- If the tenant has no brand owner,
BrandOwnerOptindoesn't apply and is ignored if sent. - Sending
MarketingOptInin this mode is rejected with400 TERMS_FIELD_NOT_APPLICABLE. - You never send a
BrandOwnerId— the API resolves it from the tenant automatically. - Both acceptances persist per user independently (
AcceptedTermsAndConditionsscoped to the promotion / loyalty program,BrandOwnerOptinscoped to the brand owner). Declining marketing never affects the T&C acceptance.
Which endpoints enforce this
These endpoints run the gate described above (only when the target promotion is issy-managed):
- Registration: Register user (and by phone), Register with email and password and Register with verification code (
/auth/register/confirm) — the last two only if you sendPromotionId. - Participation: all participation endpoints in Participations —
createparticipation,createparticipationwithcode,createparticipationwithphoto,beginparticipation,beginparticipationwithpopandsaveparticipation.
The login endpoints (/auth/login, /auth/login/confirm, /auth/google, /auth/facebook) never block on T&C, but they accept the same consent fields and store them — see Showing the consent UI.
Each accepts these optional request fields:
{
// ...the endpoint's other fields...
"AcceptedTermsAndConditions": true,
"MarketingOptIn": true, // simple mode only
"BrandOwnerOptin": true // specific mode only
}
If something required is missing, the endpoint returns 412 Precondition Failed:
{
"success": false,
"errorCode": "TERMS_NOT_ACCEPTED",
"message": "The user must accept the Terms & Conditions / Privacy Policy before continuing.",
"requiredAcceptances": ["AcceptedTermsAndConditions", "BrandOwnerOptin"]
}
If you send a field that doesn't apply to the tenant's mode, you get 400 TERMS_FIELD_NOT_APPLICABLE instead.
redeemreward and transfercredits are not gated by this — they never require T&C fields, regardless of the promotion's configuration.
Checking status without triggering an action
To check or set a user's consent independently of registering/participating (e.g. to show your own consent UI upfront, before the user does anything else), use GET/POST /termsacceptance. The same status shape is also embedded as termsAcceptance in GET /consumerprofile and in every login response (/auth/login, /auth/login/confirm, /auth/google, /auth/facebook) when you pass a PromotionId.
Showing the consent UI
Everything your frontend needs to render the consent checkboxes comes from the display block of GET /termsacceptance/{promotion id} — no user needed, so you can call it on a register screen — or of GET /termsacceptance/{promotion id}/{userid}. Never hardcode texts, URLs or the brand owner name.
Terms & Conditions text is HTML. display.termsAndConditionsText (and the termsAndConditions field of Promotions / Loyalty Programs / Rewards) is a string containing HTML produced by a rich-text editor in issy's backoffice, for example:
"<p>This is the space where the Terms and Conditions of the <b><u>Hennessy Bahamas Loyalty Program</u></b> will be displayed.</p>"
Render it as HTML (typically inside a modal opened from the "Terms and Conditions" link), sanitized first (e.g. with DOMPurify) — never as plain text and never injected unsanitized. It can be null if the promotion has no T&C loaded.
What to show depends on the tenant's mode (display.specificTermsAndConditions):
Specific mode — mirrors issy's own web experience:
- Optional heading above the checkboxes when
display.showTitleistrue:title1, followed bytitle2(as a link totitle2Urlwhen present). - ☐ Required — "I accept the [Terms and Conditions] and the [Privacy Policy] of the promotion."
- "Terms and Conditions" opens
termsAndConditionsText(HTML) in a modal. - "Privacy Policy" opens
policyPrivacyUrl. If it'snull, show only the Terms and Conditions part. - Sent as
AcceptedTermsAndConditions. Blocks the user from continuing while unchecked.
- "Terms and Conditions" opens
- ☐ Optional — "I agree to be contacted by [brandOwner.name]" (link to
brandOwner.siteUrl). Only whendisplay.brandOwnerisn'tnull. Sent asBrandOwnerOptin(trueorfalse), never blocks.
Simple mode:
- ☐ Required — "I accept the [Terms and Conditions] of the promotion and issy's [Terms and Conditions] and [Privacy Policy]."
- The promotion's "Terms and Conditions" opens
termsAndConditionsText(HTML) in a modal. - issy's documents are public pages: open
display.issyTermsUrlanddisplay.issyPrivacyPolicyUrl(same pages issy's own web uses). - Sent as
AcceptedTermsAndConditions.
- The promotion's "Terms and Conditions" opens
- ☐ Optional — "I agree to receive marketing communications". Sent as
MarketingOptIn. We recommend it unchecked by default.
Skip the consent UI entirely when display.requireIssyTermsAcceptance is false (client-managed promotion).
Where to show it. Show the checkboxes on the screen where the user enters their email/phone, before the verification code is requested — consent should be collected before personal data is used. Keep their values and send them with the call that completes the flow (/auth/register/confirm, /auth/login/confirm, /auth/login, /auth/google or /auth/facebook, always together with PromotionId):
- On registration, the gate applies: missing a required acceptance returns
412 TERMS_NOT_ACCEPTED. - On login, the values are stored but never block the login. Check
user.termsAcceptancein the response: iftermsAndConditions.required && !termsAndConditions.accepted(e.g. the user didn't see the checkbox), show the consent UI and save it withPOST /termsacceptancebefore letting them participate — participation endpoints enforce the gate anyway.
An existing user who already accepted (specific mode) doesn't need the checkbox again: use termsAcceptance.termsAndConditions.accepted and brandOwnerOptin.required to decide what to show.
Age Gate
A Promotion or Loyalty Program can require a minimum age to register, participate or redeem rewards. This is read from two fields returned by every catalog endpoint (GET /promotions, GET /promotions/{id}, GET /loyaltyprograms, GET /loyaltyprograms/{id}, GET /rewards/{promotionid}, GET /rewards/{promotionid}/{id}), both camelCase and never null:
| Field | Type | Meaning |
|---|---|---|
requireAgegateConfiguration | boolean | true = the API enforces the minimum age. false when not configured. |
minimumAge | integer | Minimum age in years. 0 when not configured. |
The gate only applies when requireAgegateConfiguration is true and minimumAge is greater than 0. When it applies, the request is rejected with BIRTHDATE_REQUIRED if the user's birth date is unknown, or with AGE_REQUIREMENT_NOT_MET if the user is younger than minimumAge.
Where the birth date comes from:
- Registration and login (Register user and by phone, Register with email and password, Register with verification code, Login with email and password, Login with verification code, Login with Google, Login with Facebook): the birth date already stored for the user; if there is none,
DateOfBirthfrom the request body (yyyy-MM-dd). On the account endpoints, wherePromotionIdis optional, the gate only runs ifPromotionIdis sent. The check runs before the account, profile or session is created. - Participation and redemption (
createparticipation,createparticipationwithcode,createparticipationwithphoto,beginparticipation,beginparticipationwithpop,saveparticipation,redeemreward): the birth date stored for the participating user. The check runs before anything is created (participation, receipt image, redemption, credit deduction). Forredeemreward, the configuration of the Loyalty Program the reward belongs to is evaluated (rewards don't have their own age gate, so theirrequireAgegateConfiguration/minimumAgeare alwaysfalse/0).
DateOfBirth is accepted by every registration and login endpoint, whether or not the promotion has an age gate. It is only stored when the user has no birth date yet — an existing birth date is never overwritten, so sending a different date can't change the result of the gate. For age gates configured as year of birth only, send yyyy-01-01.
When the date would be stored (a new account, or one without a birth date yet), it must be a real date in yyyy-MM-dd format, not in the future and no more than 120 years ago — otherwise the endpoint returns 400 INVALID_DATE_OF_BIRTH before creating the account, profile or session. For users who already have a birth date, DateOfBirth is ignored entirely, so an invalid value never causes an error for them.
When the gate rejects a request, the endpoint returns 406 Not Acceptable with one of two error codes, so the frontend can react differently:
| errorCode | What the frontend should do |
|---|---|
BIRTHDATE_REQUIRED | Ask the user for their date of birth and repeat the call with DateOfBirth (on participation/redemption endpoints, log in again sending DateOfBirth). |
AGE_REQUIREMENT_NOT_MET | The user doesn't meet the minimum age — show a "not eligible" message. |
{
"success": false,
"errorCode": "BIRTHDATE_REQUIRED",
"message": "The user's date of birth is required to validate the minimum age."
}
{
"success": false,
"errorCode": "AGE_REQUIREMENT_NOT_MET",
"message": "The user does not meet the minimum age required."
}
User Session
Some actions can only be performed by a user who has actually logged in — not just any UserId you pass in a request body. This only applies to issy-managed promotions/loyalty programs (APIConsumerManager: Issy, the default — same setting used by Terms & Conditions Acceptance above). For client-managed promotions, nothing on this page applies — keep sending UserId as before, with no session.
Getting a session
Every successful call to Account Authentication — email/password, verification code, Google, or Facebook — returns a sessionId alongside the user object. Keep it together with the account's id (use id as UserId from then on for that user).
Endpoints that require it
For an issy-managed loyalty program (isLoyaltyProgram: true), these endpoints require both UserId (the account id, not an email/phone/anonymous identifier) and SessionId:
- Create participation / Create loyalty participation with POP code
- Begin participation WITH proof of purchase / Complete participation process
- Redeem reward / Transfer credits — always require it when issy-managed, since these only ever apply to loyalty programs
Send SessionId alongside UserId in the request body:
{
// ...the endpoint's other fields...
"UserId": "{account id from Account Authentication}",
"SessionId": "{sessionId from Account Authentication}"
}
If missing or invalid, the endpoint fails before doing anything else:
| HTTP | errorCode | Cause |
|---|---|---|
| 401 | LOGIN_REQUIRED | No SessionId was sent. |
| 401 | INVALID_SESSION | SessionId doesn't match an active session, is expired, or belongs to a different UserId. |
error response example
{
"success": false,
"errorCode": "LOGIN_REQUIRED",
"message": "This promotion requires the user to be logged in."
}