Users
User management
User registration by email
Anonymous, per-promotion registration: creates (or reuses) a user tied to UserId (the user's email) and this PromotionId. No password is involved — this is the lightweight identity used by the rest of the participation endpoints (beginparticipation, consumerprofile, etc, passing the same UserId).
POST /registeruser
REQUEST
{
"PromotionId": "{Promotion id}",
"UserId": "{user email}",
"Trafficsource": "{traffic source or UTM value}",
"FullName": "{Optional - Name of user}",
"Nickname": "{Optional - Name to show in profile and rankings}",
"City": "{Optional - City from where the user participates}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": true,
"BrandOwnerOptin": true
}
AcceptedTermsAndConditions/MarketingOptIn/BrandOwnerOptin are only relevant when this promotion is issy-managed — see Terms & Conditions Acceptance. Omit MarketingOptIn and BrandOwnerOptin entirely if you don't know the tenant's mode; only send whichever one applies.
If the promotion has the Age Gate enabled (requireAgegateConfiguration: true and minimumAge > 0), DateOfBirth becomes effectively required unless the user already has a birth date stored. A stored birth date always wins and is never overwritten — DateOfBirth is only used (and saved) when the user has none. The check runs before the user/profile is created or updated.
RESPONSE (200)
{
"success": true
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | PROMOTION_NOT_FOUND | PromotionId doesn't exist for this tenant. |
| 412 | TERMS_NOT_ACCEPTED | Promotion is issy-managed and required acceptance is missing — see requiredAcceptances. |
| 400 | TERMS_FIELD_NOT_APPLICABLE | Sent BrandOwnerOptin/MarketingOptIn for the wrong tenant mode. |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | The promotion has the Age Gate enabled and no birth date is available (neither one already stored for the user nor DateOfBirth in the body). The user and profile are not created. |
| 406 | AGE_REQUIREMENT_NOT_MET | The promotion has the Age Gate enabled and the user is younger than minimumAge. The user and profile are not created. |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
error response example
{
"success": false,
"errorCode": "TERMS_NOT_ACCEPTED",
"message": "The user must accept the Terms & Conditions / Privacy Policy before continuing.",
"requiredAcceptances": ["AcceptedTermsAndConditions"]
}
User registration by phone
Same as email registration, but UserId is the user's phone number. Email here is only a reference field (not used as the identifier).
POST /registeruserbyphone
REQUEST
{
"PromotionId": "{Promotion id}",
"UserId": "{user phone number}",
"Trafficsource": "{traffic source or UTM value}",
"FullName": "{Optional - Name of user}",
"Nickname": "{Optional - Name to show in profile and rankings}",
"City": "{Optional - City from where the user participates}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd}",
"Email": "{Optional - reference email}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": true,
"BrandOwnerOptin": true
}
RESPONSE (200)
{
"success": true
}
Same error table as User registration by email above.
Account Authentication
These endpoints manage a full user account (email/password, verification code, or social login), as an alternative to the anonymous email/phone registration above. On success they return the account's id and a sessionId. Use id as UserId in the rest of the endpoints, the same way the anonymous registration flow works. sessionId is only required for issy-managed loyalty programs — see User Session in Get Started; for client-managed promotions it can be ignored.
All account endpoints below still require the standard issy-token + issy-tenant-id headers described in Get Started.
Register with email and password
POST /auth/register
REQUEST
{
"Email": "{user email}",
"Password": "{user password}",
"FirstName": "{Optional - user first name}",
"LastName": "{Optional - user last name}",
"Nickname": "{Optional - Name to show in profile and rankings}",
"City": "{Optional - City from where the user participates}",
"CountryCode": "{Optional - user country code}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd (yyyy-01-01 for year-of-birth-only age gates). Only stored if the user has none yet}",
"PromotionId": "{Optional - Promotion id, needed only to enforce Terms & Conditions acceptance and the Age Gate}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": true,
"BrandOwnerOptin": true
}
If PromotionId is omitted, the account is created without any T&C check at all. If it's sent, the Terms & Conditions Acceptance gate applies exactly like the other gated endpoints.
If PromotionId is sent and that promotion has the Age Gate enabled (requireAgegateConfiguration: true and minimumAge > 0), DateOfBirth is required and validated before the account is created.
RESPONSE (200)
{
"success": true,
"sessionId": "{use this for issy-managed loyalty endpoints, see User Session in Get Started}",
"user": {
"id": "{user identifier, use this value as UserId}",
"userName": "{user email}",
"firstName": "{user first name}",
"lastName": "{user last name}",
"email": "{user email}",
"phone": "{user phone}",
"city": "{user city}",
"nickname": "{user nickname}",
"countryCode": "{user country code}"
}
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | EMAIL_PASSWORD_REQUIRED | Missing Email/Password. |
| 400 | USER_ALREADY_EXISTS | A user with that email already exists. |
| 400 | USER_CREATION_FAILED | ASP.NET Identity rejected the account (e.g. weak password) — message has the details. |
| 412 | TERMS_NOT_ACCEPTED | Only if PromotionId was sent and the gate applies. |
| 400 | TERMS_FIELD_NOT_APPLICABLE | Only if PromotionId was sent and the wrong opt-in field for the tenant's mode was included. |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user has no birth date stored and DateOfBirth wasn't sent. |
| 406 | AGE_REQUIREMENT_NOT_MET | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user is younger than minimumAge. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
error response example
{
"success": false,
"errorCode": "USER_ALREADY_EXISTS",
"message": "A user with this email already exists."
}
Register with verification code
Two-step alternative to Register with email and password that requires proving ownership of the email or phone before the account is created — no account exists until the code is confirmed, so there's no unverified/"ghost" account left behind if the user never completes step 2.
Step 1 — request a code
POST /auth/register/request-code
REQUEST
{
"Email": "{user email - send this OR PhoneNumber, not both}",
"PhoneNumber": "{user phone number - alternative to Email}",
"PromotionId": "{Optional - if the promotion has a custom SMS message configured, it's used instead of the generic one. No effect on email codes.}"
}
Sends a 6-digit code by SMS (if PhoneNumber) or email (if Email). Rate limited per identifier: max 3 requests / 10 minutes, 5 / hour.
RESPONSE (200)
{
"success": true,
"tokenId": "{opaque id, pass it back to /auth/register/confirm}",
"channel": "{Email or Phone - where the code was sent}",
"expiresInSeconds": 600
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | EMAIL_OR_PHONE_REQUIRED | Neither or both of Email/PhoneNumber were sent. |
| 400 | ALREADY_REGISTERED | That email/phone already has a full account — use Login instead. |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Step 2 — confirm the code and complete registration
POST /auth/register/confirm
REQUEST
{
"TokenId": "{tokenId from the request-code response}",
"Code": "{6-digit code the user received}",
"Email": "{must match what was sent to request-code, if Email was the channel}",
"PhoneNumber": "{must match what was sent to request-code, if PhoneNumber was the channel}",
"Password": "{user password}",
"FirstName": "{Optional - user first name}",
"LastName": "{Optional - user last name}",
"Nickname": "{Optional - Name to show in profile and rankings}",
"City": "{Optional - City from where the user participates}",
"CountryCode": "{Optional - user country code}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd}",
"PromotionId": "{Optional - associates the account with this promotion; also needed to enforce Terms & Conditions and the Age Gate, same as Register with email and password}",
"SourceCode": "{Optional - attribution code (traffic source or promotion code), stored as-is, not validated}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": true,
"BrandOwnerOptin": true
}
The password is only requested here, after the code is verified — step 1 never asks for it.
RESPONSE (200)
{
"success": true,
"sessionId": "{use this for issy-managed loyalty endpoints, see User Session in Get Started}",
"user": {
"id": "{user identifier, use this value as UserId}",
"userName": "{user email}",
"firstName": "{user first name}",
"lastName": "{user last name}",
"email": "{user email}",
"phone": "{user phone}",
"city": "{user city}",
"nickname": "{user nickname}",
"countryCode": "{user country code}"
}
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | CODE_NOT_FOUND | TokenId doesn't match a pending code. |
| 401 | CODE_INVALID | Code doesn't match. |
| 401 | CODE_EXPIRED | The code expired (10 minutes after request-code). |
| 401 | CODE_ALREADY_USED | The code was already used to complete a registration. |
| 400 | EMAIL_PASSWORD_REQUIRED | Missing Password, or the Email/PhoneNumber doesn't match the one verified in step 1. |
| 400 | USER_ALREADY_EXISTS | An account for that email/phone already exists (race with another registration). |
| 400 | USER_CREATION_FAILED | ASP.NET Identity rejected the account (e.g. weak password) — message has the details. |
| 412 | TERMS_NOT_ACCEPTED | Only if PromotionId was sent and the gate applies. |
| 400 | TERMS_FIELD_NOT_APPLICABLE | Only if PromotionId was sent and the wrong opt-in field for the tenant's mode was included. |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | Only if PromotionId was sent and the promotion has the Age Gate enabled: no birth date is available (neither one stored on the existing guest profile being upgraded nor DateOfBirth). |
| 406 | AGE_REQUIREMENT_NOT_MET | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user is younger than minimumAge. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Login with email and password
POST /auth/login
REQUEST
{
"Email": "{user email - send this OR PhoneNumber}",
"PhoneNumber": "{user phone number - alternative to Email}",
"Password": "{user password}",
"PromotionId": "{Optional - Promotion id. When sent, the response includes a termsAcceptance block for that promotion and the promotion's Age Gate is enforced}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd (yyyy-01-01 for year-of-birth-only age gates). Only stored if the user has none yet}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": false,
"BrandOwnerOptin": false
}
RESPONSE (200)
{
"success": true,
"sessionId": "{use this for issy-managed loyalty endpoints, see User Session in Get Started}",
"user": {
"id": "{user identifier, use this value as UserId}",
"userName": "{user email}",
"firstName": "{user first name}",
"lastName": "{user last name}",
"email": "{user email}",
"phone": "{user phone}",
"city": "{user city}",
"nickname": "{user nickname}",
"countryCode": "{user country code}",
"termsAcceptance": {
"specificTermsAndConditions": true,
"termsAndConditions": { "required": true, "accepted": false },
"brandOwnerOptin": { "applicable": true, "required": true, "accepted": null },
"marketingOptin": { "accepted": null }
}
}
}
termsAcceptance (same shape as Get Terms Acceptance status below) is only present when PromotionId was sent in the request and matches an existing promotion.
The optional AcceptedTermsAndConditions / MarketingOptIn / BrandOwnerOptin fields (only used together with PromotionId) let you send the consent captured on the login screen: they're stored but never block the login — only the fields that apply to the tenant's mode are kept, and a false/missing AcceptedTermsAndConditions never revokes an earlier acceptance. user.termsAcceptance reflects the status after saving them. See Showing the consent UI.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | EMAIL_PASSWORD_REQUIRED | Missing Password, or neither/both of Email/PhoneNumber were sent. |
| 401 | INVALID_LOGIN_ATTEMPT | No account for that email/phone, or wrong password. |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user has no birth date stored and DateOfBirth wasn't sent. |
| 406 | AGE_REQUIREMENT_NOT_MET | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user is younger than minimumAge. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Login with verification code
Passwordless alternative to Login with email and password — for an existing account only (unlike registration, this never creates a new one).
Step 1 — request a code
POST /auth/login/request-code
REQUEST
{
"Email": "{user email - send this OR PhoneNumber}",
"PhoneNumber": "{user phone number - alternative to Email}",
"PromotionId": "{Optional - if the promotion has a custom SMS message configured, it's used instead of the generic one. No effect on email codes.}"
}
Same rate limit as Register with verification code: max 3 requests / 10 minutes, 5 / hour per identifier.
RESPONSE (200)
{
"success": true,
"tokenId": "{opaque id, pass it back to /auth/login/confirm}",
"channel": "{Email or Phone - where the code was sent}",
"expiresInSeconds": 600
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | EMAIL_OR_PHONE_REQUIRED | Neither or both of Email/PhoneNumber were sent. |
| 404 | USER_NOT_FOUND | No account exists for that email/phone. |
| 429 | TOO_MANY_REQUESTS | Rate limit exceeded. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Step 2 — confirm the code
POST /auth/login/confirm
REQUEST
{
"TokenId": "{tokenId from the request-code response}",
"Code": "{6-digit code the user received}",
"PromotionId": "{Optional - Promotion id. When sent, the response includes a termsAcceptance block for that promotion and the promotion's Age Gate is enforced}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd (yyyy-01-01 for year-of-birth-only age gates). Only stored if the user has none yet}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": false,
"BrandOwnerOptin": false
}
If the Age Gate rejects the login, the code has already been used — the user must request a new one. Send DateOfBirth on the first attempt to avoid this.
RESPONSE (200)
Same shape as Login with email and password above (sessionId + user, including user.termsAcceptance when PromotionId was sent). The consent fields behave the same way: stored, never blocking.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | CODE_NOT_FOUND | TokenId doesn't match a pending code. |
| 401 | CODE_INVALID | Code doesn't match. |
| 401 | CODE_EXPIRED | The code expired (10 minutes after request-code). |
| 401 | CODE_ALREADY_USED | The code was already used to complete a login. |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user has no birth date stored and DateOfBirth wasn't sent. |
| 406 | AGE_REQUIREMENT_NOT_MET | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user is younger than minimumAge. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Login with Google
The frontend uses Google Sign-In to obtain an id_token, then sends it here to validate it and find or create the matching account.
POST /auth/google
REQUEST
{
"IdToken": "{Google Sign-In id_token}",
"PromotionId": "{Optional - Promotion id. When sent, the response includes a termsAcceptance block for that promotion and the promotion's Age Gate is enforced}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd (yyyy-01-01 for year-of-birth-only age gates). Only stored if the user has none yet}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": false,
"BrandOwnerOptin": false
}
RESPONSE (200)
{
"success": true,
"sessionId": "{use this for issy-managed loyalty endpoints, see User Session in Get Started}",
"user": {
"id": "{user identifier, use this value as UserId}",
"userName": "{user email}",
"firstName": "{user first name}",
"lastName": "{user last name}",
"email": "{user email}",
"phone": "{user phone}",
"city": "{user city}",
"nickname": "{user nickname}",
"countryCode": "{user country code}",
"termsAcceptance": "{Only when PromotionId was sent - same shape as in Login with email and password}"
}
}
If no account exists for the Google account's email, a new one is created automatically (AccountOrigin = Google). It accepts an optional PromotionId to enforce the Age Gate and to return user.termsAcceptance. It never blocks on Terms & Conditions — even when it creates a new account — but it stores the optional consent fields if you send them (same rules as Login with email and password). If the age gate rejects the request, no account and no session are created; ask the user for their date of birth and repeat the call with the same token plus DateOfBirth.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | ID_TOKEN_REQUIRED | Missing IdToken. |
| 401 | INVALID_GOOGLE_TOKEN | The id_token is invalid or expired. |
| 400 | GOOGLE_EMAIL_MISSING | The Google account didn't expose an email. |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user has no birth date stored and DateOfBirth wasn't sent. |
| 406 | AGE_REQUIREMENT_NOT_MET | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user is younger than minimumAge. |
| 400 | USER_CREATION_FAILED | Account creation failed — message has the details. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | GOOGLE_SIGNIN_NOT_CONFIGURED | Server-side Google Client Id isn't configured. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Login with Facebook
The frontend uses Facebook Login to obtain an access_token, then sends it here. The token is validated against the Facebook Graph API (/me?fields=id,email,name) to find or create the matching account.
POST /auth/facebook
REQUEST
{
"AccessToken": "{Facebook access_token}",
"PromotionId": "{Optional - Promotion id. When sent, the response includes a termsAcceptance block for that promotion and the promotion's Age Gate is enforced}",
"DateOfBirth": "{Optional - user date of birth in format yyyy-mm-dd (yyyy-01-01 for year-of-birth-only age gates). Only stored if the user has none yet}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": false,
"BrandOwnerOptin": false
}
RESPONSE (200)
{
"success": true,
"sessionId": "{use this for issy-managed loyalty endpoints, see User Session in Get Started}",
"user": {
"id": "{user identifier, use this value as UserId}",
"userName": "{user email}",
"firstName": "{user first name}",
"lastName": "{user last name}",
"email": "{user email}",
"phone": "{user phone}",
"city": "{user city}",
"nickname": "{user nickname}",
"countryCode": "{user country code}",
"termsAcceptance": "{Only when PromotionId was sent - same shape as in Login with email and password}"
}
}
If no account exists for that email, a new one is created automatically (AccountOrigin = Facebook). It accepts an optional PromotionId to enforce the Age Gate and to return user.termsAcceptance. It never blocks on Terms & Conditions — even when it creates a new account — but it stores the optional consent fields if you send them (same rules as Login with email and password). If the age gate rejects the request, no account and no session are created; ask the user for their date of birth and repeat the call with the same token plus DateOfBirth.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | ACCESS_TOKEN_REQUIRED | Missing AccessToken. |
| 401 | INVALID_FACEBOOK_TOKEN | The access_token is invalid or expired. |
| 400 | FACEBOOK_EMAIL_MISSING | The user didn't grant the email permission on Facebook (required, since the account is matched by email). |
| 400 | INVALID_DATE_OF_BIRTH | DateOfBirth was sent but isn't a real past date in yyyy-MM-dd format (or is more than 120 years ago), and it would be stored: the account is new, or it has no birth date yet. Ignored for users who already have one. |
| 406 | BIRTHDATE_REQUIRED | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user has no birth date stored and DateOfBirth wasn't sent. |
| 406 | AGE_REQUIREMENT_NOT_MET | Only if PromotionId was sent and the promotion has the Age Gate enabled: the user is younger than minimumAge. |
| 400 | USER_CREATION_FAILED | Account creation failed — message has the details. |
| 401 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
User Participations Profile
Get information of user participations, credits, ranking position, Terms & Conditions status and SMS notification preferences — scoped to one promotion.
GET /consumerprofile/{promotion id}/{userid}
RESPONSE (200)
{
"userId": "{User identifier}",
"promotionId": "{Promotion id}",
"tenantId": "{TenantId}",
"name": "{Name provided by the user}",
"nickname": "{User nickname}",
"city": "{Name of the city provided by the user}",
"email": "{User Email}",
"phoneNumber": "{User PhoneNumber}",
"creditsEarned": "{Total credits earned in this promotion}",
"creditsSended": "{Total credits sended throw transfers in this promotion}",
"creditsReceived": "{Total credits received throw transfers in this promotion}",
"creditsRedeemed": "{Total credits redeemed for rewards in this promotion}",
"creditsAvailable": "{Total credits available in this promotion}",
"registrationCredits": "{Welcome credits granted at registration for this loyalty promotion, if any. Included in creditsAvailable but NOT in creditsEarned — clients computing a spendable balance from the individual credit fields must add this one in too}",
"rankingPosition": "{If the promotion manages participation ranking, this field shows the user's current position}",
"termsAcceptance": {
"specificTermsAndConditions": true,
"termsAndConditions": { "required": true, "accepted": false },
"brandOwnerOptin": { "applicable": true, "required": true, "accepted": null },
"marketingOptin": { "accepted": null }
},
"notificationPreferences": {
"receiptApproved": true,
"photoApproved": true,
"receiptInvalidated": true,
"photoInvalidated": true,
"topRankingEntered": true
},
"availableNotifications": ["ReceiptApproved", "PhotoApproved", "TopRankingEntered"],
"participations": [
{
"promotionLocationUserId": "{Promotion location user participation Id}",
"dateCreated": "{Participation creation date}",
"status": "{Redemption status of this participation record: 0 => Redeemed, 1 => Pending, 2 => Invalidated}",
"credits": "{Credits earned for this participation}",
"location": "{Location of the participation proof of purchase}",
"hasReceiptImage": "{true if this participation has a stored proof-of-purchase image — from either beginparticipationwithpop/saveparticipation (an invoice) or createparticipationwithphoto}",
"hasPopCode": "{true if this participation was created via createparticipationwithcode (a POP code, not an image)}",
"hasPhotoImage": "{true only when hasReceiptImage's image came from createparticipationwithphoto specifically, not the invoice flow — added 2026-08}",
"imageUrl": "{Blob filename of the stored proof-of-purchase image when hasReceiptImage is true, null otherwise. Not a full URL — resolve it against the public image CDN the same way as other issy image filenames (e.g. a promotion's mainImageUrl): {cdnBase}/{account}/API/GetImage/{imageUrl}}"
}
],
"transfers": [
{
"dateCreated": "{Transfer creation date}",
"userId": "{User identifier}",
"transferType": "{1 => Incoming, 2 => Outgoing}",
"credits": "{transfer credits}"
}
],
"rewardRedemptions": [
{
"dateCreated": "{Redemption creation date}",
"promotionId": "{Reward Id}",
"promotionName": "{Reward Name}",
"redemptionCode": "{QR url or alphanumeric code}",
"redemptionUrl": "{Reference Url to go to redeem the reward code}",
"bannerMessage": "{Per-code banner message configured for this code (Multicode) or reward, when set - null otherwise}",
"credits": "{Used credits}",
"status": "{Redemption status of reward, possible values => PendingReview, Approved, Invalid, Coupon Expired}",
"location": "{Location where the QR code was redeemed}"
}
]
}
termsAcceptance has the same shape used by Get Terms Acceptance status. If the promotion isn't issy-managed, it always reports termsAndConditions: {required: false, accepted: true}.
notificationPreferences and availableNotifications drive the profile's notifications screen — see Notification Preferences. notificationPreferences is what the user chose (every type defaults to true); availableNotifications lists the types this loyalty program actually sends, so show a toggle only for those.
The
statusfield insideparticipations[]is aRedemptionStatus(0/1/2 as shown above) — a different enum from thePromotionUserStatusused elsewhere in this API (which uses 1/2/3 for Pending/Approved/Invalid). Don't confuse the two when reading this specific array.
hasReceiptImage and hasPopCode are mutually exclusive in practice — a participation comes from either an image (invoice or photo) or the POP-code flow, never both. Use them to tell the two apart (e.g. to decide whether to offer a "view receipt" affordance) instead of inferring it from other fields.
To tell which kind of image it is, check hasPhotoImage only when hasReceiptImage is true: hasPhotoImage: true means createparticipationwithphoto, false means the invoice flow (beginparticipationwithpop/saveparticipation). hasPhotoImage is computed from a field added to ProofOfPurchase in 2026-08 — participations created before that (by either flow) report hasPhotoImage: false, so treat that as "invoice or unknown" rather than a confirmed invoice for old data.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | USER_NOT_FOUND | userid doesn't match a registered user. |
| 404 | PROMOTION_NOT_FOUND | promotion id doesn't exist. |
| 404 | PROFILE_NOT_FOUND | The user has no participation profile for this promotion yet (hasn't registered/participated). |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
All User Participations
Get the full list of a user's participations across all promotions of the tenant (not scoped to a single promotion, unlike User Participations Profile above). Reward redemptions and Loyalty Program participations are excluded from this list.
GET /consumerparticipations/{userid}
RESPONSE (200)
[
{
"id": "{Promotion location user participation Id}",
"promotionLocationId": "{Promotion location id}",
"promotionLocation": {
"promotion": {
"id": "{Promotion id}",
"name": "{Promotion name}",
"code": "{Promotion unique code}"
}
},
"status": "{Participation status 0 => ActivationStarted, 1 => PendingReview, 2 => Approved, 3 => Invalid}",
"statusDescription": "{Localized description of status}",
"dateCreated": "{Participation creation date}",
"dateModified": "{Participation last update date}",
"creditsAssigned": "{Credits assigned by this participation}",
"approvedCreditsAssigned": "{Credits confirmed once the participation is approved}",
"isWinner": "{Whether this participation won a prize}",
"redemptionStatus": "{0 => Redeemed, 1 => Pending, 2 => Invalidated}"
}
]
statushere isPromotionUserStatus(0/1/2/3) — note this is a different enum and different field from thestatusshown underparticipations[]in User Participations Profile above, which isRedemptionStatus. Same field name, different meaning depending on the endpoint.
The response returns the raw participation records — more fields than shown above may be present; the fields listed here are the ones most relevant for consumption. Results are ordered by DateModified descending. An empty array ([]) means the user exists but has no participations, not an error.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | USER_NOT_FOUND | userid doesn't match a registered user. |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Terms & Conditions Acceptance
Query or save a user's Terms & Conditions / Privacy Policy consent for a promotion, independently of registration or participation. Useful to check status upfront and show your own consent UI, or to record consent (or revoke it, by sending false) at any other point in your flow. See Terms & Conditions Acceptance in Get Started for the full concept.
Get Terms display
Returns only what's needed to render the consent UI for a promotion / loyalty program — no user involved, so use it on register screens where the user doesn't exist yet. See Showing the consent UI in Get Started for how to use each field.
GET /termsacceptance/{promotion id}?lang={optional: en | es | fr | pt}
RESPONSE (200) - specific mode
{
"display": {
"requireIssyTermsAcceptance": true,
"specificTermsAndConditions": true,
"termsAndConditionsText": "<p>This is the space where the Terms and Conditions of the <b><u>Hennessy Bahamas Loyalty Program</u></b> will be displayed.</p>",
"policyPrivacyUrl": "https://www.example.com/privacy",
"showTitle": true,
"title1": "To continue please read the",
"title2": "DATA PROTECTION INFORMATION",
"title2Url": "https://www.example.com/data-protection",
"brandOwner": {
"id": "{brand owner id}",
"name": "Hennessy",
"siteUrl": "https://www.hennessy.com"
},
"issyTermsUrl": null,
"issyPrivacyPolicyUrl": null
}
}
RESPONSE (200) - simple mode
{
"display": {
"requireIssyTermsAcceptance": true,
"specificTermsAndConditions": false,
"termsAndConditionsText": "<p>...</p>",
"policyPrivacyUrl": null,
"showTitle": false,
"title1": null,
"title2": null,
"title2Url": null,
"brandOwner": null,
"issyTermsUrl": "https://{issy web}/{tenant}/Terms/en",
"issyPrivacyPolicyUrl": "https://{issy web}/{tenant}/PrivacyPolicy/en"
}
}
Field reference (display):
requireIssyTermsAcceptance:falsewhen the promotion is client-managed — issy doesn't track consent for it, so don't show issy's consent UI. Same value as the promotion'sAPIRequireIssyTermsAcceptance.specificTermsAndConditions: the tenant's mode.termsAndConditionsText: the promotion's Terms & Conditions as an HTML string (render it sanitized, typically in a modal).nullwhen the promotion has none.- Specific mode only (
null/falsein simple mode):policyPrivacyUrl: the promotion's privacy policy URL.nullif not configured.showTitle,title1,title2,title2Url: optional heading shown above the checkboxes. The titles arenullwhenshowTitleisfalse.brandOwner: the tenant's brand owner, for the optional marketing opt-in label (name, link tositeUrl).nullif the tenant has none — then there's no brand opt-in.
- Simple mode only (
nullin specific mode):issyTermsUrl,issyPrivacyPolicyUrl: public pages with issy's own Terms & Conditions and Privacy Policy, in the requested language.
lang only affects issyTermsUrl/issyPrivacyPolicyUrl. If omitted, the language of the promotion's country is used; anything other than en, es, fr or pt falls back to en.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | PROMOTION_NOT_FOUND | promotion id doesn't exist or isn't currently available. |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Get Terms Acceptance status
Returns the user's consent status plus the same display block as Get Terms display.
GET /termsacceptance/{promotion id}/{userid}?lang={optional: en | es | fr | pt}
RESPONSE (200)
{
"specificTermsAndConditions": true,
"termsAndConditions": {
"required": true,
"accepted": false
},
"brandOwnerOptin": {
"applicable": true,
"required": true,
"accepted": null
},
"marketingOptin": {
"accepted": null
},
"display": {
"requireIssyTermsAcceptance": true,
"specificTermsAndConditions": true,
"termsAndConditionsText": "<p>This is the space where the Terms and Conditions of the <b><u>Hennessy Bahamas Loyalty Program</u></b> will be displayed.</p>",
"policyPrivacyUrl": "https://www.example.com/privacy",
"showTitle": true,
"title1": "To continue please read the",
"title2": "DATA PROTECTION INFORMATION",
"title2Url": "https://www.example.com/data-protection",
"brandOwner": {
"id": "{brand owner id}",
"name": "Hennessy",
"siteUrl": "https://www.hennessy.com"
},
"issyTermsUrl": null,
"issyPrivacyPolicyUrl": null
}
}
Field reference:
specificTermsAndConditions: the tenant's mode (see Terms & Conditions Acceptance in Get Started). Alwaystrue/false.termsAndConditions.required:falseonly when the promotion isn't issy-managed — in that caseacceptedis always reportedtrueand the rest of the object is inert.termsAndConditions.accepted: whether this user has already accepted. In simple mode this is alwaysfalse(no persisted state — see Get Started).brandOwnerOptin.applicable:trueonly in specific mode with a tenant that has a brand owner.brandOwnerOptin.required:trueonly when applicable and the user has never answered the brand-owner opt-in. It means "an answer is still needed" (trueorfalse), not "must accept" — marketing consent is optional.brandOwnerOptin.accepted:true= opted in,false= declined,null= never answered (or not applicable).marketingOptin.accepted: only meaningful in simple mode — the user's currentMarketingOptInvalue on their account (nullif never set); alwaysnullin specific mode.display: see Get Terms display.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | PROMOTION_NOT_FOUND | promotion id doesn't exist. |
| 404 | USER_NOT_FOUND | userid doesn't match a registered user. |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Save Terms Acceptance
Unlike the gate on registration/participation endpoints, this endpoint never returns 412 — it always saves whatever you send (including false, to revoke).
POST /termsacceptance
REQUEST
{
"UserId": "{user identifier}",
"PromotionId": "{Promotion id}",
"AcceptedTermsAndConditions": true,
"MarketingOptIn": true,
"BrandOwnerOptin": true
}
Send only the field(s) that apply to the tenant's mode — MarketingOptIn in simple mode, BrandOwnerOptin in specific mode (never both). If the promotion isn't issy-managed, the call succeeds without persisting anything. In specific mode, send BrandOwnerOptin as true or false (the checkbox value) so the user's answer is recorded; the two opt-ins are stored independently. If the tenant has no brand owner, BrandOwnerOptin is ignored.
AcceptedTermsAndConditions is optional: if you leave it out, the user's stored acceptance doesn't change. Send false only when you really want to revoke it. Revoking blocks the user, because accepted Terms & Conditions are required to keep using the program.
To change only the brand opt-in, send just BrandOwnerOptin. This works for a "stop marketing from {brand}" button (false) or to opt back in (true):
REQUEST - brand opt-in only
{
"UserId": "{user identifier}",
"PromotionId": "{Promotion id}",
"BrandOwnerOptin": false
}
The current value is in termsAcceptance.brandOwnerOptin.accepted of User Participations Profile.
RESPONSE (200)
{
"success": true
}
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | USER_AND_PROMOTION_REQUIRED | UserId/PromotionId missing. |
| 404 | PROMOTION_NOT_FOUND | PromotionId doesn't exist. |
| 404 | USER_NOT_FOUND | UserId doesn't match a registered user. |
| 400 | TERMS_FIELD_NOT_APPLICABLE | Sent BrandOwnerOptin in simple mode, or MarketingOptIn in specific mode. |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Notification Preferences
When a loyalty program is issy-managed, issy sends SMS notifications to its participants. Each type can be turned on or off by the program in the backoffice, and by each user for themselves. A user receives a notification only when both have it on, and only if their account has a phone number.
These are transactional notifications about the user's own activity in the program. They are separate from the brand's marketing consent (BrandOwnerOptin / MarketingOptIn), which is managed with Save Terms Acceptance.
| Type | Sent when |
|---|---|
ReceiptApproved | A participation made with a receipt / invoice is approved. |
PhotoApproved | A participation made with a photo is approved. |
ReceiptInvalidated | A participation made with a receipt / invoice is invalidated. |
PhotoInvalidated | A participation made with a photo is invalidated. |
TopRankingEntered | The user enters the program's top ranking for the first time. Sent once per user and program, never again. |
Participations made with a POP code don't send notifications: they are validated at the moment the code is used.
Preferences are per user and per loyalty program. To read them, use User Participations Profile: notificationPreferences has the user's current values, and availableNotifications lists the types the program sends.
Update notification preferences
Partial update: only the preferences present in the request change, and the ones you don't send keep their value. Send all of them from a settings screen, or only the toggle the user just changed.
POST /updatenotificationpreferences
REQUEST
{
"UserId": "{user identifier}",
"PromotionId": "{Loyalty program id}",
"ReceiptApproved": true,
"PhotoApproved": true,
"ReceiptInvalidated": false,
"PhotoInvalidated": false,
"TopRankingEntered": true
}
UserId accepts the user id, email or phone, like the other user endpoints. Every preference is optional (true / false), but send at least one.
RESPONSE (200)
{
"preferences": {
"receiptApproved": true,
"photoApproved": true,
"receiptInvalidated": false,
"photoInvalidated": false,
"topRankingEntered": true
},
"availableNotifications": ["ReceiptApproved", "PhotoApproved", "TopRankingEntered"]
}
The response always has all the preferences with their current values, plus availableNotifications (same as in the profile), so you can refresh the screen without another call.
You can save a preference for a type that isn't in availableNotifications. It's stored, but has no effect until the program turns that type on.
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 400 | USER_AND_PROMOTION_REQUIRED | UserId/PromotionId missing. |
| 400 | NO_PREFERENCES_SENT | The request has no preference to update. |
| 404 | PROMOTION_NOT_FOUND | PromotionId doesn't exist or isn't currently available. |
| 406 | PROMOTION_INVALID_FOR_ACTION | PromotionId is not a loyalty program. |
| 404 | USER_NOT_FOUND | UserId doesn't match a registered user. |
| 404 | PROFILE_NOT_FOUND | The user has no participation profile for this program yet (hasn't registered/participated). |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |
Promotion Ranking
Shows the list of the first N places in the promotion participation ranking and the current position of the user that is sent as a parameter.
GET /ranking/{promotion id}/{userid}/{positions}
RESPONSE (200)
{
"userRankingPosition": "{Current position of the user passed as parameter}",
"credits": "{Credits accumulated by the user}",
"ranking": [
{
"nickname": "{User nickname}",
"credits": "{Credits accumulated by the user}",
"userRankingPosition": "{Position}",
"city": "{User city}"
}
]
}
positions controls how many entries ranking returns (defaults to 10 if not a valid number).
Error responses:
| HTTP | errorCode | Cause |
|---|---|---|
| 404 | PROMOTION_NOT_FOUND | promotion id doesn't exist. |
| 404 | USER_NOT_FOUND | userid doesn't match a registered user. |
| 404 | PROFILE_NOT_FOUND | The user has no participation profile for this promotion yet. |
| 401 / 406 | INVALID_HEADER_CONTENT | Missing/invalid issy-token or issy-tenant-id. |
| 500 | INTERNAL_ERROR | Unexpected server error. |