Skip to content

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.

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, has selection: "CATALOG" and campaignId: null.

Add up to 1,000 recipients:

{
"campaignId": "1e2d3c4b-5a69-4c1f-9a52-8d0e4f4b9a3e",
"recipients": [
{
"externalRecipientId": "player-1001",
"label": "Ann Smith",
"email": "[email protected]",
"message": "Happy birthday from the VIP team!",
"locale": "en-US",
"managerEmail": "[email protected]"
},
{
"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.

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:

  1. Store the links of all CREATED results.
  2. Fix the REJECTED recipients.
  3. 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.

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.

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.

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.

  • GET /personal-links/{linkId} reads one link.
  • GET /personal-links lists 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).

  1. Call GET /personal-links?updatedSince=<instant>&limit=100 and follow nextCursor until it is null.
  2. Replace your copy of each link. The last item is the newest change.
  3. Next time, pass the newest updatedAt minus a few minutes.

Send Z, or encode + as %2B. The event feed has the full change history.

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.

  1. The player opens claimUrl, chooses a gift and enters an address on the gift site.
  2. Luxorr places an order with source: PERSONAL_LINK and personalLinkId. It starts PROCESSING.
  3. You receive personal_link.claimed with orderId, and order.created if the key has ORDERS.
  4. Read the order with GET /orders/{orderId}. It contains the delivery country only.