Public merchant lookup and onboarding
Public, unauthenticated endpoints for storefront merchant lookup and merchant onboarding.
This page covers two unrelated groups of endpoints that are both public (no merchant session) rather than part of the authenticated BFF surface: the storefront's merchant/catalog lookup, and merchant onboarding.
Storefront merchant lookup
Used by the public storefront (webshop) to render a merchant's branded
page and catalog. Both are CORS-public, unauthenticated, and only return
data for merchants with status: ACTIVE.
Get a merchant by slug
GET /api/merchants/{slug}(Re-exported from src/app/api/public/merchants/[slug]/route.ts; the path
shown is the one CLAUDE.md scopes as merchant-facing.)
Response
{
"ok": true,
"data": {
"id": "...",
"slug": "...",
"name": "...",
"logoUrl": null,
"description": null,
"primaryColor": null,
"secondaryColor": null,
"currency": "EUR",
"homeUrl": null,
"termsUrl": null,
"siftSiteId": null,
"analyticsId": null,
"popularGiftCardAmount": null
}
}termsUrl is fetched from the card network's program details at request
time; if that lookup fails, it's null rather than failing the whole
request. Cache-Control: no-store is set.
Errors
| Status | Cause |
|---|---|
| 404 | No merchant matches slug, or the matched merchant's status is not ACTIVE. |
| 500 | Unexpected internal error. |
Get a merchant's catalog by slug
GET /api/merchants/{slug}/catalog(Re-exported from src/app/api/public/merchants/[slug]/catalog/route.ts.)
Returns the merchant's sellable gift card offers. Refund cards are excluded (they are internal-use-only and can't be auto-issued through this storefront flow).
Response
{
"ok": true,
"data": [
{
"id": "...",
"merchantId": "...",
"name": "...",
"description": null,
"imageUrl": null,
"type": "GIFTCARD_DIGITAL",
"currency": "EUR",
"priceOptions": [2500, 5000, 10000],
"minValue": 2500,
"maxValue": 10000
}
],
"meta": { "popularAmount": null }
}priceOptions is in minor units. For fixed-price offers it's a single
value; for variable-pricing offers with no explicit denominations set, it's
auto-generated from the min/max range using "nice" step sizes (roughly 4-6
options), so it may not match what the merchant actually configured if
they intended a continuous range rather than fixed steps.
Errors
Same as Get a merchant by slug.
Onboarding
apply/ is not the real onboarding entry point
CLAUDE.md's documented scope names src/app/api/apply/... as the
onboarding entry point. Reading it shows a single, minimal POST that
creates a draft merchant and a user with no password or auth provider set
(the code's own comment says "in a real app, we'd handle password/auth
provider here"), and it does not match the real 4-step onboarding flow
(Company information, Directors, Documents, Review & submit) already
documented in
Getting started: creating an account.
That flow is actually backed by src/app/api/onboarding/* and
src/app/api/public/merchant/onboarding-check, documented below instead.
apply/ appears to be an earlier or unused stub; it is not documented here
and should not be treated as a supported entry point until confirmed
otherwise.
All onboarding submission and document endpoints below require an authenticated session (created during account signup, before KYB submission); only the status-check lookup is fully public.
Save or submit onboarding details
POST /api/onboarding/submitSaves a draft of the company/KYB onboarding form, or performs a full
submission. Only allowed while the merchant's status is DRAFT or
WAITING_FOR_INFO.
Request body
| Field | Type | Required for draft | Required for submit |
|---|---|---|---|
isDraft | boolean | no (defaults to a full submit if omitted/false) | n/a |
registrationNumber | string | no | yes |
vatNumber | string | no | yes |
tradingName | string | no | no |
currency | string | no | no (defaults to EUR) |
numberOfLocations | string | no | no (defaults to "0") |
legalAddress.line1 / .city / .postalCode / .country | string | no | yes |
legalAddress.state | string | no | no |
directors | array of { name, role } | no | yes, at least one |
documents | array of { type, url, name? } | no | no |
termsAccepted | boolean | no | yes |
privacyAccepted | boolean | no | yes |
On a full submission (isDraft not true), this also creates or refreshes
an IDenfy verification session and emails the merchant a status update with
the verification link; both are best-effort and a failure here does not
fail the submission itself.
Response
{ "ok": true }Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 400 | Session user has no associated merchant; body fails to parse; or (full submit only) required fields are missing, or terms/privacy were not accepted. |
| 403 | Merchant status is not DRAFT or WAITING_FOR_INFO (application is under review and cannot be edited). |
| 500 | Unexpected internal error. |
Upload an onboarding document
POST /api/onboarding/uploadUploads a KYB document (for example, Certificate of Incorporation or director ID) to object storage under a tenant-scoped key.
Request
multipart/form-data with a single field:
| Field | Type | Required |
|---|---|---|
file | file | yes |
Response
{
"ok": true,
"url": "https://<bucket>.s3.<region>.amazonaws.com/<merchantId>/onboarding/documents/<uuid>-<name>.<ext>",
"previewUrl": "https://...(signed, expires in 1 hour)",
"key": "<merchantId>/onboarding/documents/<uuid>-<name>.<ext>",
"originalName": "...",
"filename": "<uuid>-<name>.<ext>"
}url is the permanent (unsigned) S3 URL, intended for storing in the
documents array passed to onboarding/submit. The bucket is private, so
this URL alone is not browsable; use previewUrl for immediate display, or
Get a signed document URL later.
Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 400 | Session user has no associated merchant, or no file provided. |
| 500 | Upload failed. |
Get a signed document URL
GET /api/onboarding/document?url=<S3 object URL>Exchanges a previously stored document URL for a short-lived signed URL (60 seconds), since the underlying bucket is private.
Query parameters
| Param | Required | Notes |
|---|---|---|
url | yes | Must be a virtual-hosted-style S3 URL for the configured bucket. |
Response
{ "ok": true, "url": "https://...(signed, expires in 60 seconds)" }Errors
| Status | Cause |
|---|---|
| 401 | No session. |
| 400 | Missing url, or url doesn't parse as a valid S3 URL for the configured bucket. |
| 403 | Non-admin session user has no associated merchant, or the document's storage key does not belong to their merchant (tenant check on the key prefix). Admins bypass this check. |
| 500 | Failed to generate the signed URL. |
Check onboarding status by email
POST /api/public/merchant/onboarding-checkFully public, unauthenticated. Rate-limited per client IP and per email address (separately). Used for a "what's the status of my application" self-service lookup, without requiring a login.
Request body
| Field | Type | Required |
|---|---|---|
email | string (valid email) | yes |
Response
{
"ok": true,
"data": {
"recognized": true,
"activated": false,
"merchantStatus": "KYB_IN_PROGRESS",
"summary": {
"text": "...",
"nextStep": "..."
}
}
}recognized is false if no user matches the email at all (the data
shape is narrower in that case: just recognized and summary, no
activated/merchantStatus). merchantStatus is one of DRAFT,
SUBMITTED, WAITING_FOR_INFO, KYB_IN_PROGRESS, DECISION_PENDING,
ACTIVE, INACTIVE, SUSPENDED, DECLINED, or null if the email
matches an admin account with no merchant, or a user with no merchant
profile linked yet. summary.text is a plain-language status message
intended to be shown directly to the merchant; it never echoes raw status
codes. The response intentionally never reveals whether an email exists in
a way that would help enumerate accounts beyond the generic "not found"
message.
Errors
| Status | Cause |
|---|---|
| 429 | Rate limit exceeded for this IP or this email address; Retry-After header indicates the wait. |
| 400 | email missing or not a valid email address. |
| 500 | Unexpected internal error. |