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
Get a sandbox key
To start integrating you need a test API key (sp_test_…). Email support@social-pay.io (or your Social Pay contact) and we'll provision one on the shared Social Pay Sandbox merchant — usually same day. Once you're ready for production, onboarding (KYB) issues your sp_live_… keys from the merchant dashboard.
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: https://api.social-pay.io/functions/v1/socialpay-checkout. 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. Prices are TTC (tax included); there is no separate tax line. currency is an ISO-4217 code and defaults to EUR — EUR is the only settlement currency today (multi-currency is on the roadmap).
Goods, shipping & rewards
Put the goods in items and the delivery fee in order.shipping — never fold shipping into item prices. Then:
- Charged =
total= items + shipping. - Rewarded = items only (goods, TTC). Cashback, ambassador bonus, the platform (Social Pay+) bonus, the referral discount, and loyalty GMV are all computed on the goods and never on shipping.
order.total must equal items + shipping within ±0.01, else the request is rejected with total_mismatch.
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 | The sandbox payment-intent is temporarily unavailable — use the simulator (POST /session/{id}/simulate) instead. |
Field-level validation failures return a details array:
{
"error": "Validation error",
"details": [ "order.total: total must have at most 2 decimal places", "return_url: return_url must be an http(s) URL" ]
}
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.
GET /sessions is isolated per key — you only ever list your own sessions. A dedicated sandbox merchant is available on request if you need full isolation for load testing.| Card number | Outcome |
|---|---|
4242 4242 4242 4242 | Success |
4000 0000 0000 0002 | Declined |
4000 0000 0000 9995 | Insufficient funds |
End-to-end referral test
The referral loop is the product — here's the full flow in the sandbox, no real money. The key link: the share_code returned in step 1 is the referral_code you pass in step 2 (the shareable link is share_link = …/r/<share_code>).
- First purchase — create a session,
simulatea success; the response returns the buyer'sshare_idandshare_code(they're now an ambassador).GET /status/{id}returns the sameshare_code+share_link.
S=$(curl -s -X POST "$BASE/session" -H "Authorization: Bearer sp_test_…" \
-H "Content-Type: application/json" \
-d '{"order":{"total":50,"items":[{"product_id":"SKU1","name":"Tee","price":50,"quantity":1}]},"return_url":"https://s.example/ok"}' | jq -r .session_id)
curl -s -X POST "$BASE/session/$S/simulate" -H "Content-Type: application/json" -d '{"card":"4242424242424242"}'
# → { "status":"success", "share_id":"shr_4n9jx9md", "share_code":"7vg0Oe", … }
- Referred purchase — a friend buys with that
share_codeasreferral_code; the ambassador earns cashback (on goods only). If you offer the referred shopper a discount, you apply it in theorder.totalyou send (see How the referral discount works below):
curl -s -X POST "$BASE/session" -H "Authorization: Bearer sp_test_…" \
-H "Content-Type: application/json" \
-d '{"order":{"total":50,"referral_code":"7vg0Oe","items":[{"product_id":"SKU1","name":"Tee","price":50,"quantity":1}]},"return_url":"https://s.example/ok"}'
# simulate this session too → the ambassador's cashback is created, status "pending"
Cashback lifecycle
A referred, completed order creates a cashback event for the ambassador with status pending, computed as the merchant's rate × the goods value (shipping excluded), capped by the merchant's per-order maximum. It moves to released (payable) after the merchant's configured hold — 14 days by default. If the order is refunded while the cashback is still pending, it is cancelled (clawback); rates, the hold period, and the per-order cap are set by the merchant.
How the referral discount works
The referred shopper's discount is a merchant-configured rate (set per merchant, optionally boosted for a limited window). Social Pay surfaces it — the shareable referral page shows the discounted item prices — but does not reduce the charge itself: the API charges exactly the order.total you send. To grant the discount, you (the merchant) create the session with the reduced item prices / total; Social Pay never silently changes the amount. So: the discount is merchant-funded (you decide and apply it), while the ambassador's cashback is also merchant-funded but paid out by Social Pay after the hold. The optional platform (Social Pay+) bonus is the only reward Social Pay funds.
Endpoints
Create a session
Create a checkout session and receive a hosted checkout_url to redirect the buyer to. Send an Idempotency-Key header to make retries safe — a replay with the same key + body returns the original session (header Idempotent-Replayed: true); the same key with a different body returns 422. Sessions expire after 30 minutes.
Body parameters
| Field | Description | |
|---|---|---|
order.items[] | required | Line items (goods only): product_id, name, price (per unit, TTC), quantity, optional product_url. Line value = price × quantity. |
order.total | required | Charged amount. Must equal items + shipping (±0.01), decimal euros ≤2dp. |
order.shipping | optional | Shipping fee (default 0). Charged in total but earns no reward. Never fold it into item prices — see Money. |
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 via POST /customer. |
return_url | required | Where the buyer is sent after checkout (Social Pay appends ?session_id=). Must be an http(s) URL. |
metadata | optional | Your own identifiers, stored verbatim. Returned on the authenticated channels only — GET /sessions and the webhook — never on the buyer-facing session reads. Max 20 keys / 4096 bytes. |
curl -X POST "$BASE/session" \
-H "Authorization: Bearer sp_test_…" \
-H "Content-Type: application/json" \
-d '{
"order": {
"total": 24.80,
"shipping": 4.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"
}'
Node & PHP
const res = await fetch(`${BASE}/session`, {
method: "POST",
headers: { Authorization: `Bearer ${process.env.SOCIALPAY_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
order: { total: 24.80, shipping: 4.90, items: [{ product_id: "SKU1", name: "Demo tee", price: 19.90, quantity: 1 }] },
customer: { email: "buyer@example.com" },
return_url: "https://your-store.example/thanks",
}),
});
const { checkout_url } = await res.json(); // redirect the buyer here
$ch = curl_init("$BASE/session");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer {$key}", "Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode([
"order" => ["total" => 24.80, "shipping" => 4.90, "items" => [["product_id"=>"SKU1","name"=>"Demo tee","price"=>19.90,"quantity"=>1]]],
"customer" => ["email" => "buyer@example.com"],
"return_url" => "https://your-store.example/thanks",
]),
]);
$out = json_decode(curl_exec($ch), true); // $out["checkout_url"]
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": 4.90, "total": 24.80,
"customer_email": "buyer@example.com",
"expires_at": "2026-07-24T12:30:00Z"
}
subtotal is the goods (items); shipping is the rest of the charged total. Returns 410 session_expired for a pending session past its expiry (sessions live 30 minutes).
Endpoints
Attach customer
{id} is the session id (chk_…). The customer object is optional at session creation — complete it here before payment (contact + shipping address). Authorized by the session id, so no bearer token.
Body — customer fields (all optional)
| Field | Description |
|---|---|
email | Buyer email. |
first_name / last_name | Buyer name. |
address_line1 / address_line2 | Street address. |
city / postal_code | City and postal/ZIP code. |
country | ISO 3166-1 alpha-2 (e.g. FR). |
phone | See the note below — normally set by the hosted checkout, not by a partner. |
phone_proof — a token issued by the hosted checkout after the buyer verifies their number by OTP. Server-to-server integrations don't have that token, so omit phone; the hosted checkout collects and verifies it. A phone sent without a valid proof is silently dropped.curl -X POST "$BASE/customer/chk_7oztnk9kh2lvlwbv" \
-H "Content-Type: application/json" \
-d '{ "customer": { "email": "buyer@example.com", "first_name": "Léa",
"last_name": "Martin", "address_line1": "12 rue de Rivoli",
"postal_code": "75001", "city": "Paris", "country": "FR" } }'
# → { "success": true }
Endpoints
Cancel a session
Cancel a not-yet-paid session (merchant-scoped). Idempotent — cancelling an already-cancelled session returns 200; a paid or otherwise terminal session returns 409 session_not_cancelable. After cancelling, payment-intent and simulate on that session return 409.
curl -X POST "$BASE/session/chk_…/cancel" -H "Authorization: Bearer sp_test_…"
# → { "session_id": "chk_…", "status": "cancelled" }
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 the sandbox payment-intent is temporarily unavailable (use the simulator instead).
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, cancelled. |
order_id | Present on success. |
share_id | Referral share id, present on success. |
share_code / share_link | The buyer's referral code (use as referral_code) and shareable URL, 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.
Endpoints
List objects
List your sessions, payments or refunds — newest first. This is how you reconcile, or recover from a missed webhook, without contacting support.
sp_test_ key against a live merchant is refused with 403 test_key_on_live_merchant (and the converse), so test credentials can never read production data.Query parameters
| Param | Description |
|---|---|
limit | 1–100, default 20. |
offset | ≥ 0, default 0. Next page: offset += limit while has_more is true. |
status | Filter by status (e.g. success, paid). |
created_after / created_before | ISO-8601 timestamps — the usual way to replay a window you missed. |
# everything that completed since yesterday, page by page
curl "$BASE/sessions?status=success&created_after=2026-07-23T00:00:00Z&limit=50" \
-H "Authorization: Bearer sp_live_…"
{
"object": "sessions",
"data": [
{ "id": "chk_…", "status": "success", "total": 24.80, "order_id": "ORD-…",
"payment_id": "PAY-…", "metadata": { "my_order_ref": "CMD-1234" }, "created_at": "2026-07-24T…" }
],
"has_more": false
}
Webhooks
Events & delivery
Register HTTPS endpoints in the dashboard under Integrations → Webhooks. Each delivery is signed and carries a livemode flag so you can filter test traffic. The checkout.session.completed event is the authoritative payment confirmation — the browser never confirms a charge.
| 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. |
failed/expired webhook yet — detect those by polling GET /status/{id} (it returns failed/expired + an error_code). A live refund returns pending_provider; the acquirer-settlement / settlement-failure signal is on the roadmap. Reconcile via GET /refunds in the meantime.Delivery & retries
Delivery is at-least-once and order is not guaranteed — always dedupe on the event id. Respond 2xx within 8 seconds; a non-2xx or timeout is retried up to 8 times with exponential backoff (≈ 1m → 5m → 30m → 2h → 6h → 12h → 24h), then the delivery is marked dead. Dead deliveries can be replayed, and the signing secret rotated, from the dashboard (Integrations → Webhooks).
Envelope
Every event shares the same envelope; data is event-specific.
{ "id": "evt_uuid", "type": "checkout.session.completed", "created": 1753363200, "data": { /* … */ } }
checkout.session.completed — data
{
"session_id": "chk_7oztnk9kh2lvlwbv",
"order_id": "ORD-9F2A1C7B4E00",
"payment_id": "PAY-1A2B3C4D5E6F",
"merchant_id": "a1b2…",
"amount": 24.80, // charged total (goods + shipping)
"currency": "EUR",
"status": "success",
"share_id": "shr_4n9jx9md", // buyer's referral share (null if none)
"share_code": "7vg0Oe", // = referral_code a friend passes
"metadata": { "my_order_ref": "CMD-1234" }, // your identifiers, echoed back
"livemode": true // false for sandbox/test
}
payment.refunded — data
{
"payment_id": "PAY-1A2B3C4D5E6F",
"refund_event_id": "a1b2…",
"amount": 5.00,
"is_full_refund": false,
"new_payment_status": "partially_refunded",
"currency": "EUR",
"merchant_id": "a1b2…",
"livemode": true
}
new_payment_status is one of the payment statuses: pending, paid, failed, refunded, partially_refunded. A full refund yields refunded; a partial one partially_refunded. Comments (//) in the JSON above are annotations, not part of the payload.
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) {
// header = "t=1712345678,v1=abc123…"
const parts = Object.fromEntries(header.split(",").map(kv => kv.split("=")));
const { t, v1 } = parts;
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.