Checkout API · Version 1
Payments that turn every buyer into a channel.
Social Pay is a payment method that adds automatic cashback and a viral referral link on top of a licensed acquirer. One server-to-server API creates a hosted checkout, confirms payment through signed webhooks, and settles refunds — you never touch card data or hold funds.
How a payment flows
- Create a session from your server with an API key, then redirect the buyer to the returned
checkout_url. - The buyer pays on the acquirer's hosted, PCI-compliant page (with 3-D Secure).
- Social Pay confirms the payment server-to-server and fires a signed
checkout.session.completedwebhook — the browser never confirms a charge. - The buyer earns cashback and a personal referral link, and is returned to your
return_url.
Get started
Authentication
Server-to-server endpoints authenticate with a merchant API key sent as a bearer token. The merchant identity is derived from the key — you never pass a merchant id.
Authorization: Bearer sp_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
sp_live_…keys act on your production merchant;sp_test_…keys act on the shared sandbox merchant. A key can never cross environments.- Keys are created in the merchant dashboard and shown once — only a SHA-256 hash is stored. Rotate by creating a new key and revoking the old one.
- Scopes:
create_session,read_status, andrefund(opt-in, enabled per key).
Session-scoped reads (GET /session/{id}, /payment-intent, /status/{id}, /simulate) are authorized by the unguessable session id itself and take no bearer token.
Get started
Base URL & versioning
All requests are made to a single production base URL. This is API v1; breaking changes ship under a new major version, never in place.
application/json on every request and response.Concepts
Money & amounts
All amounts are decimal euros with at most two decimal places (for example 19.90), rounded half-up. A value with finer precision is rejected rather than silently rounded, so the amount on the wire always equals the amount stored. currency is an ISO-4217 code and defaults to EUR.
On POST /session, order.total must equal the sum of the line items within ±0.01.
Concepts
Rate limits
Requests are limited per fixed 60-second window. Authenticated endpoints are limited per merchant (not per key, so minting extra keys can't multiply your budget); the public simulator is limited per client IP.
| Endpoint | Limit | Scope |
|---|---|---|
POST /session | 300 / min | per merchant + environment |
POST /refunds | 60 / min | per merchant + environment |
POST /session/{id}/simulate | 60 / min | per client IP |
Over the limit returns 429 rate_limited with a Retry-After header (seconds) and a retry_after field. Back off and retry after that delay.
Concepts
Errors
Errors use standard HTTP status codes and a JSON body of the shape { "error": "…", "message": "…" }. Validation errors carry a details array instead.
| Status | Error | Meaning |
|---|---|---|
| 400 | Validation error · invalid_amount | Malformed body, or a refund amount that is negative, over the remaining balance, or has more than 2 decimals. |
| 401 | invalid_api_key | Missing or invalid bearer key. |
| 403 | insufficient_scope · test_key_on_live_merchant · sandbox_only · not_a_sandbox_session | The key lacks a scope, or key and merchant environments don't match. |
| 404 | *_not_found | No such session, payment, or merchant. |
| 409 | payment_not_refundable · idempotency_key_in_progress | Session not payable, payment not refundable, or a concurrent request holds the idempotency key. |
| 410 | session_expired | The session passed its expiry (on reads that don't poll). |
| 422 | idempotency_key_reused | The idempotency key was reused with a different request body. |
| 429 | rate_limited | Too many requests — honour Retry-After. |
| 503 | sandbox_unavailable | A sandbox payment-intent was requested before the xMoney sandbox is wired — use the simulator instead. |
Concepts
Sandbox & test cards
An sp_test_ key creates sessions on a shared sandbox merchant. Sandbox sessions are completed with a self-contained simulator (POST /session/{id}/simulate) — no real money, no card network, no acquirer dependency. Every record is flagged demo and is excluded from payouts and analytics; webhooks carry livemode: false.
| Card number | Outcome |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Declined |
4000 0000 0000 9995 | Insufficient funds |
Endpoints
Create a session
Create a checkout session and receive a hosted checkout_url to redirect the buyer to.
Body parameters
| Field | Description | |
|---|---|---|
order.items[] | required | Line items: product_id, name, price, quantity, optional product_url. |
order.total | required | Order total in decimal euros; must equal the sum of items (±0.01). |
order.currency | optional | ISO-4217, defaults to EUR. |
order.referral_code | optional | Attributes the sale to a referring ambassador. |
customer | optional | email, phone, first_name, last_name — may be completed later. |
return_url | required | Where the buyer is sent after checkout. Must be an http(s) URL. |
curl -X POST "$BASE/session" \
-H "Authorization: Bearer sp_test_…" \
-H "Content-Type: application/json" \
-d '{
"order": {
"total": 19.90,
"currency": "EUR",
"items": [{ "product_id": "SKU1", "name": "Demo tee", "price": 19.90, "quantity": 1 }]
},
"customer": { "email": "buyer@example.com" },
"return_url": "https://your-store.example/thanks"
}'
Response · 201
{
"session_id": "chk_7oztnk9kh2lvlwbv",
"checkout_url": "https://checkout.social-pay.io/checkout/session/chk_7oztnk9kh2lvlwbv"
}
Endpoints
Retrieve a session
Return the public view of a session — what the hosted checkout page renders. Authorized by the session id alone, so it never exposes the merchant uuid or PII beyond the buyer email.
{
"session_id": "chk_7oztnk9kh2lvlwbv",
"status": "pending",
"merchant": { "name": "AMO Paris", "logo_url": "https://…", "return_url": "https://…" },
"items": [ { "product_id": "SKU1", "name": "Demo tee", "price": 19.90, "quantity": 1 } ],
"currency": "EUR",
"subtotal": 19.90, "shipping": 0, "tax": 0, "total": 19.90,
"customer_email": "buyer@example.com",
"expires_at": "2026-07-24T12:30:00Z"
}
Returns 410 session_expired for a pending session past its expiry.
Endpoints
Payment intent
Return the descriptor the hosted page needs to mount the payment form. For a live session this is the signed acquirer order; for a sandbox session it's a test-mode descriptor pointing at the simulator.
{
"test_mode": true,
"simulate_url": "/socialpay-checkout/session/chk_…/simulate",
"test_cards": { "success": "4242424242424242", "declined": "4000000000000002", "insufficient_funds": "4000000000009995" }
}
Returns 409 if the session isn't payable, 410 if expired, and 503 sandbox_unavailable if a sandbox intent is requested before the xMoney sandbox is configured.
Endpoints
Simulate a payment
Complete or decline a sandbox session deterministically by test card, running the real post-payment pipeline (order, cashback, webhooks) with everything flagged demo. Returns 403 not_a_sandbox_session on a live session. A declined card leaves the session pending so you can retry with a success card.
curl -X POST "$BASE/session/chk_…/simulate" \
-H "Content-Type: application/json" \
-d '{ "card": "4242424242424242" }'
{
"status": "success",
"session_id": "chk_…",
"order_id": "ORD-…",
"payment_id": "PAY-SBX-…",
"share_id": "shr_…",
"test_mode": true
}
Endpoints
Poll status
Poll a session's status. The signed webhook is the authoritative signal — polling is a fallback. Poll until status reaches a terminal state.
| Field | Description |
|---|---|
status | One of pending, processing, success, failed, expired. |
order_id | Present on success. |
share_id | Referral share id, present on success. |
error_code | payment_declined (failed) or session_expired (expired). An expired session is reported with HTTP 200, not 410, so pollers get a terminal answer. |
{ "status": "success", "order_id": "ORD-…", "share_id": "shr_…" }
Endpoints
Refund a payment
Refund a payment in full or in part. Idempotent via the Idempotency-Key header, which is bound to the request body: replaying a key returns the stored response, and reusing it with a different body returns 422. Omit amount to refund the full remaining balance.
Body parameters
| Field | Description | |
|---|---|---|
payment_id | required | The payment to refund. Must belong to the key's merchant. |
amount | optional | Decimal euros, ≤2dp. Omit for the full remaining balance. |
reason | optional | Free-text reason, stored on the refund event. |
curl -X POST "$BASE/refunds" \
-H "Authorization: Bearer sp_test_…" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{ "payment_id": "PAY-SBX-…", "amount": 5.00 }'
{
"refund_event_id": "a1b2…",
"payment_id": "PAY-SBX-…",
"amount": 5.00,
"is_full_refund": false,
"status": "sandbox_dry_run"
}
On a live payment the status is pending_provider; in the sandbox it's sandbox_dry_run. A successful refund also emits a payment.refunded webhook.
Webhooks
Events & delivery
Register HTTPS endpoints in the dashboard under Integrations → Webhooks. Each delivery is signed, retried on non-2xx responses with exponential backoff, and carries a livemode flag so you can filter test traffic.
| Event | Fired when |
|---|---|
checkout.session.completed | A payment completes (the authoritative confirmation). |
payment.refunded | A refund is recorded. |
webhook.ping | You click Send test — use it to verify your signature check before going live. |
Webhooks
Verify a signature
Every delivery carries an X-SocialPay-Signature header of the form t=<unix>,v1=<hex>, where v1 is an HMAC-SHA256 of t + "." + rawRequestBody keyed with your endpoint's signing secret.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const { t, v1 } = parse(header); // "t=…,v1=…"
const expected = createHmac("sha256", secret)
.update(`${t}.${rawBody}`).digest("hex");
if (!timingSafeEqual(Buffer.from(v1), Buffer.from(expected))) throw new Error("bad signature");
if (Date.now() / 1000 - Number(t) > 300) throw new Error("stale timestamp");
return JSON.parse(rawBody);
}
v1 over the raw body before any JSON parsing, reject a stale t, and derive the event type and idempotency key from the signed body (id, type) — not from the advisory X-SocialPay-* headers.