Personal links
A personal link lets one player choose one gift. You deliver the link (claimUrl) through your own channel. The player opens it on the Luxorr gift site under your brand, chooses a gift and enters a delivery address. You never receive the address.
Use a personal link when the player chooses the gift or you have no address. Use a direct order when you know the gift and the address.
Personal-link endpoints need the PERSONAL_LINKS scope.
Send a batch
Section titled “Send a batch”POST /personal-links:create-batch with an Idempotency-Key header.
Select the gifts in one of two ways:
- Campaign:
{"campaignId": "…"}. Players choose from the campaign’s gifts. - Catalog:
{"brandId": "…", "productIds": ["…", "…"]}. Players choose from these gifts, under that brand. Each link, and the order from its claim, hasselection: "CATALOG"andcampaignId: null.
Add up to 1,000 recipients:
{ "campaignId": "1e2d3c4b-5a69-4c1f-9a52-8d0e4f4b9a3e", "recipients": [ { "externalRecipientId": "player-1001", "label": "Ann Smith", "message": "Happy birthday from the VIP team!", "locale": "en-US", }, { "externalRecipientId": "player-1002", "label": "Bob Jones" } ]}| Field | Rules |
|---|---|
externalRecipientId |
Your player ID, max 128 characters. Usable as a filter; the claimed order carries it too. |
label |
Required. Player name shown on the gift page, max 255 characters. |
email |
Optional. Kept with the link for your team, max 255 characters. Unique within a batch. |
message |
Personal note on the gift page, max 255 characters. |
locale |
Gift page language: en-US or another locale enabled for the tenant. Default en-US. |
customFields |
Answers to the tenant’s fields: [{fieldId, textValue, checked}], as listed in tenant.customFields. An unknown fieldId is ignored. |
managerEmail |
A team member who manages the link. |
GET /me lists the accepted locales in tenant.locales. A locale that is not enabled rejects only that recipient, with not_enabled on locale.
Results per recipient
Section titled “Results per recipient”Each recipient is validated separately. Valid recipients are created together; invalid ones are reported by index. The response is 200:
{ "results": [ { "index": 0, "externalRecipientId": "player-1001", "outcome": "CREATED", "link": { "id": "7a6b5c4d-3e2f-4a1b-9c8d-7e6f5a4b3c2d", "externalRecipientId": "player-1001", "campaignId": "1e2d3c4b-5a69-4c1f-9a52-8d0e4f4b9a3e", "selection": null, "brandId": "0a6f2e1c-3b5d-4e7f-8a9b-0c1d2e3f4a5b", "status": "ACTIVE", "claimUrl": "https://gifts.luxorr.io/claim/example-brand/Xk2p9QaB", "orderId": null, "createdAt": "2026-10-05T09:12:44.512731Z", "approvedAt": null, "claimedAt": null, "cancelledAt": null, "updatedAt": "2026-10-05T09:12:44.512731Z" } }, { "index": 1, "externalRecipientId": "player-1002", "outcome": "REJECTED", "errors": [{ "field": "locale", "code": "not_enabled", "message": "…" }] } ]}Partial errors:
- Store the links of all
CREATEDresults. - Fix the
REJECTEDrecipients. - Send only those recipients in a new batch, with a new
Idempotency-Key.
Do not resend the full batch. Every recipient in a new batch gets a new link.
Recipient error codes: required and too_long on label; invalid, too_long and duplicate on email; too_long on message and externalRecipientId; not_enabled on locale; invalid on customFields[i].fieldId when it is not a UUID, and on customFields when a required field is missing or an answer does not fit; manager_not_found on managerEmail.
Events. Every new link records a personal_link.created event, whichever channel created it. The creating key does not receive it as a webhook, because the batch response already contains the links; it is in the key’s event feed. Other keys with PERSONAL_LINKS and access to the brand receive the webhook.
Batch errors
Section titled “Batch errors”A problem with the batch as a whole answers 422. No link is created.
| Code | Meaning |
|---|---|
campaign_not_found |
The campaign does not exist or is outside the key’s brands. |
campaign_not_active |
The campaign is not active. |
brand_not_found |
The brand does not exist or is outside the key’s brands. |
no_products |
productIds is empty. |
product_not_found |
A product is not in the catalog. |
product_not_available |
A product cannot be offered. |
no_recipients |
recipients is empty. |
too_many_recipients |
More than 1,000 recipients. |
Neither campaignId nor brandId, both, or brandId without productIds answers 400 invalid_request.
Statuses
Section titled “Statuses”| Status | Meaning |
|---|---|
REQUESTED |
Waiting for approval. Cannot be claimed. |
ACTIVE |
Can be claimed. |
CLAIMED |
The player chose a gift. orderId is the resulting order. |
CANCELLED |
Withdrawn. The player sees the link as expired. |
EXPIRED |
Reserved. Links do not expire. |
Links start ACTIVE, or REQUESTED when the tenant requires approval. Approval happens in the workspace; keys cannot approve.
Timestamps
Section titled “Timestamps”| Field | Meaning |
|---|---|
createdAt |
Created. |
approvedAt |
Approved. null if the link started ACTIVE or is still waiting. |
claimedAt |
Claimed; null until CLAIMED. |
cancelledAt |
Cancelled; null unless CANCELLED. |
updatedAt |
Last change. Use it to sync. |
All timestamps are UTC with up to six decimal places.
Read and find links
Section titled “Read and find links”GET /personal-links/{linkId}reads one link.GET /personal-linkslists links within the key’s brands, newest first. Filters:externalRecipientId,campaignId,status,updatedSince.
With updatedSince, the list contains links changed at or after that instant, oldest change first (by updatedAt, then id).
- Call
GET /personal-links?updatedSince=<instant>&limit=100and follownextCursoruntil it isnull. - Replace your copy of each link. The last item is the newest change.
- Next time, pass the newest
updatedAtminus a few minutes.
Send Z, or encode + as %2B. The event feed has the full change history.
Cancel a link
Section titled “Cancel a link”POST /personal-links/{linkId}:cancel with {"reason": "Player self-excluded"} withdraws a REQUESTED or ACTIVE link. The reason is visible to your team only.
An already CANCELLED link answers 200 unchanged: the first reason stays and no event is sent. A CLAIMED or EXPIRED link answers 409 not_cancellable with currentStatus. To stop a claimed gift, cancel its order while the order is REQUESTED or PROCESSING.
Claims
Section titled “Claims”- The player opens
claimUrl, chooses a gift and enters an address on the gift site. - Luxorr places an order with
source: PERSONAL_LINKandpersonalLinkId. It startsPROCESSING. - You receive
personal_link.claimedwithorderId, andorder.createdif the key hasORDERS. - Read the order with
GET /orders/{orderId}. It contains the delivery country only.