Social Pay Social Pay

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.

Self-contained sandbox Signed webhooks Idempotent refunds
Base URL https://api.social-pay.io/functions/v1/socialpay-checkout

How a payment flows

  1. Create a session from your server with an API key, then redirect the buyer to the returned checkout_url.
  2. The buyer pays on the acquirer's hosted, PCI-compliant page (with 3-D Secure).
  3. Social Pay confirms the payment server-to-server and fires a signed checkout.session.completed webhook — the browser never confirms a charge.
  4. 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.

No account required to evaluate: a sandbox key + this reference are all you need to run the full flow (create → simulate → webhook → refund) end-to-end.

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.

http
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, and refund (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.

Machine-readable spec
openapi.yaml (OpenAPI 3.1) — import into Postman, Insomnia, or a client generator.
Content type
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 EUREUR 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.shippingnever 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.

EndpointLimitScope
POST /session300 / minper merchant + environment
POST /refunds60 / minper merchant + environment
POST /session/{id}/simulate60 / minper 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.

StatusErrorMeaning
400Validation error · invalid_amountMalformed body, or a refund amount that is negative, over the remaining balance, or has more than 2 decimals.
401invalid_api_keyMissing or invalid bearer key.
403insufficient_scope · test_key_on_live_merchant · sandbox_only · not_a_sandbox_sessionThe key lacks a scope, or key and merchant environments don't match.
404*_not_foundNo such session, payment, or merchant.
409payment_not_refundable · idempotency_key_in_progressSession not payable, payment not refundable, or a concurrent request holds the idempotency key.
410session_expiredThe session passed its expiry (on reads that don't poll).
422idempotency_key_reusedThe idempotency key was reused with a different request body.
429rate_limitedToo many requests — honour Retry-After.
503sandbox_unavailableThe sandbox payment-intent is temporarily unavailable — use the simulator (POST /session/{id}/simulate) instead.

Field-level validation failures return a details array:

json — 400
{
  "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.

Never send real card or personal data to the sandbox. A test key can only ever operate on a sandbox merchant — it can never trigger a real charge or refund. Test keys share one Sandbox merchant, but 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 numberOutcome
4242 4242 4242 4242Success
4000 0000 0000 0002Declined
4000 0000 0000 9995Insufficient 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>).

  1. First purchase — create a session, simulate a success; the response returns the buyer's share_id and share_code (they're now an ambassador). GET /status/{id} returns the same share_code + share_link.
cURL
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", … }
  1. Referred purchase — a friend buys with that share_code as referral_code; the ambassador earns cashback (on goods only). If you offer the referred shopper a discount, you apply it in the order.total you send (see How the referral discount works below):
cURL
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

POST/sessionAuth: API key · scope create_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

FieldDescription
order.items[]requiredLine items (goods only): product_id, name, price (per unit, TTC), quantity, optional product_url. Line value = price × quantity.
order.totalrequiredCharged amount. Must equal items + shipping (±0.01), decimal euros ≤2dp.
order.shippingoptionalShipping fee (default 0). Charged in total but earns no reward. Never fold it into item prices — see Money.
order.currencyoptionalISO-4217, defaults to EUR.
order.referral_codeoptionalAttributes the sale to a referring ambassador.
customeroptionalemail, phone, first_name, last_name — may be completed later via POST /customer.
return_urlrequiredWhere the buyer is sent after checkout (Social Pay appends ?session_id=). Must be an http(s) URL.
metadataoptionalYour 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
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

javascript — node
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
php
$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

json
{
  "session_id": "chk_7oztnk9kh2lvlwbv",
  "checkout_url": "https://checkout.social-pay.io/checkout/session/chk_7oztnk9kh2lvlwbv"
}

Endpoints

Retrieve a session

GET/session/{id}Auth: session id

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.

json
{
  "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

POST/customer/{id}Auth: session id

{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)

FieldDescription
emailBuyer email.
first_name / last_nameBuyer name.
address_line1 / address_line2Street address.
city / postal_codeCity and postal/ZIP code.
countryISO 3166-1 alpha-2 (e.g. FR).
phoneSee the note below — normally set by the hosted checkout, not by a partner.
Phone: a phone number is stored only when accompanied by a valid 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
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

POST/session/{id}/cancelAuth: API key · scope create_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
curl -X POST "$BASE/session/chk_…/cancel" -H "Authorization: Bearer sp_test_…"
# → { "session_id": "chk_…", "status": "cancelled" }

Endpoints

Payment intent

GET/session/{id}/payment-intentAuth: session id

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.

json — sandbox test mode
{
  "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

POST/session/{id}/simulateAuth: session id · sandbox only

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
curl -X POST "$BASE/session/chk_…/simulate" \
  -H "Content-Type: application/json" \
  -d '{ "card": "4242424242424242" }'
json — 200
{
  "status": "success",
  "session_id": "chk_…",
  "order_id": "ORD-…",
  "payment_id": "PAY-SBX-…",
  "share_id": "shr_…",
  "test_mode": true
}

Endpoints

Poll status

GET/status/{id}Auth: session id

Poll a session's status. The signed webhook is the authoritative signal — polling is a fallback. Poll until status reaches a terminal state.

FieldDescription
statusOne of pending, processing, success, failed, expired, cancelled.
order_idPresent on success.
share_idReferral share id, present on success.
share_code / share_linkThe buyer's referral code (use as referral_code) and shareable URL, present on success.
error_codepayment_declined (failed) or session_expired (expired). An expired session is reported with HTTP 200, not 410, so pollers get a terminal answer.
json — success
{ "status": "success", "order_id": "ORD-…", "share_id": "shr_…" }

Endpoints

Refund a payment

POST/refundsAuth: API key · scope refund

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

FieldDescription
payment_idrequiredThe payment to refund. Must belong to the key's merchant.
amountoptionalDecimal euros, ≤2dp. Omit for the full remaining balance.
reasonoptionalFree-text reason, stored on the refund event.
cURL
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 }'
json — 200
{
  "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

GET/sessionsAuth: API key · scope read_status
GET/paymentsAuth: API key · scope read_status
GET/refundsAuth: API key · scope read_status

List your sessions, payments or refunds — newest first. This is how you reconcile, or recover from a missed webhook, without contacting support.

Results are scoped to the key's merchant and its environment: an 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

ParamDescription
limit1–100, default 20.
offset≥ 0, default 0. Next page: offset += limit while has_more is true.
statusFilter by status (e.g. success, paid).
created_after / created_beforeISO-8601 timestamps — the usual way to replay a window you missed.
cURL
# 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_…"
json — 200
{
  "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.

EventFired when
checkout.session.completedA payment completes (the authoritative confirmation).
payment.refundedA refund is recorded.
webhook.pingYou click Send test — use it to verify your signature check before going live.
Failures & settlement: there is no 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.

json
{ "id": "evt_uuid", "type": "checkout.session.completed", "created": 1753363200, "data": { /* … */ } }

checkout.session.completeddata

json
{
  "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.refundeddata

json
{
  "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.

javascript
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);
}
Compute 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.