openapi: 3.1.0
info:
  title: Social Pay Checkout API
  version: "1.0.0"
  description: |
    Server-to-server API for creating Social Pay checkout sessions, polling their
    status, issuing refunds, and receiving signed webhooks. Social Pay is a
    payment method that adds automatic cashback + viral referral on top of a
    licensed acquirer (xMoney); Social Pay never holds funds and card data is
    entered only on the acquirer's hosted page.

    ## Authentication
    Server-to-server endpoints (`POST /session`, `POST /refunds`) require a
    merchant API key as a bearer token: `Authorization: Bearer sp_live_…`
    (production) or `sp_test_…` (sandbox). The merchant identity is derived from
    the key. Keys are created in the merchant dashboard and shown once. Scopes:
    `create_session`, `read_status`, `refund` (refund is opt-in).

    Session-scoped endpoints (`GET /session/{id}`, `GET /session/{id}/payment-intent`,
    `GET /status/{id}`, `POST /session/{id}/simulate`) are authorized by the
    unguessable session id itself (a capability token) and take no bearer token.
    The `read_status` scope is reserved for a future bearer-authenticated status
    lookup; today's `GET /status/{id}` is capability-token authorized and does not
    check it.

    ## Money
    All amounts are **decimal euros with at most 2 decimal places** (e.g. `19.90`),
    rounded half-up. Values with more precision are rejected. `currency` is ISO-4217
    (default `EUR`).

    ## Rate limits
    Requests are rate-limited per fixed 60-second window. Authenticated endpoints
    are limited **per merchant** (not per key): `POST /session` 300/min,
    `POST /refunds` 60/min. The public simulator (`POST /session/{id}/simulate`)
    is limited per client IP (60/min). Over the limit returns **HTTP 429** with a
    `Retry-After` header (seconds) and a `retry_after` field; retry after that delay.

    ## Test mode (sandbox)
    An `sp_test_` key creates sessions on a sandbox merchant. Sandbox sessions are
    completed with the self-contained simulator (`POST /session/{id}/simulate`) —
    no real money, no PSP dependency. All resulting data is flagged demo and never
    enters payouts or analytics; webhooks carry `livemode: false`.

    Test cards: `4242424242424242` = success · `4000000000000002` = declined ·
    `4000000000009995` = insufficient_funds.
servers:
  - url: https://fzttibmnhhhigvpuqnky.supabase.co/functions/v1/socialpay-checkout
    description: Production

security:
  - apiKey: []

paths:
  /session:
    post:
      summary: Create a checkout session
      description: >
        Server-to-server. Returns a hosted `checkout_url` to redirect the buyer to.
        Sessions **expire 30 minutes** after creation; after that reads report
        `expired` and the buyer must start a new session.
      security: [{ apiKey: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateSessionRequest' }
      responses:
        '201':
          description: Session created
          content:
            application/json:
              schema:
                type: object
                properties:
                  session_id: { type: string, example: chk_7oztnk9kh2lvlwbv }
                  checkout_url: { type: string, format: uri, example: https://checkout.social-pay.io/checkout/session/chk_7oztnk9kh2lvlwbv }
        '400': { $ref: '#/components/responses/ValidationError' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: >
            Environment mismatch (`test_key_on_live_merchant`, `sandbox_only`),
            `merchant_mismatch`, or merchant not active.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }
        '404': { description: Merchant not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '429': { $ref: '#/components/responses/RateLimited' }

  /session/{id}:
    get:
      summary: Get session details
      security: []
      parameters: [{ $ref: '#/components/parameters/SessionId' }]
      responses:
        '200':
          description: Session
          content: { application/json: { schema: { $ref: '#/components/schemas/Session' } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '410': { description: Session expired }

  /session/{id}/payment-intent:
    get:
      summary: Get the payment descriptor for the hosted page
      description: >
        For a live session, returns the signed acquirer order to mount the
        payment form. For a SANDBOX session, returns a test-mode descriptor
        pointing at the simulator (no PSP).
      security: []
      parameters: [{ $ref: '#/components/parameters/SessionId' }]
      responses:
        '200':
          description: Live payment descriptor OR sandbox test-mode descriptor
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/PaymentIntentLive'
                  - $ref: '#/components/schemas/PaymentIntentTestMode'
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Session not payable }
        '410': { description: Session expired }
        '503':
          description: >
            `sandbox_unavailable` — a sandbox session's payment-intent is requested
            but the xMoney sandbox credentials are not configured. Use
            `POST /session/{id}/simulate` (the self-contained simulator) instead.
          content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } }

  /session/{id}/simulate:
    post:
      summary: Simulate a payment (sandbox only)
      description: >
        Completes or declines a SANDBOX session deterministically by test card,
        running the real post-payment pipeline (order, cashback, webhooks) with
        everything flagged demo. Returns 403 on a live session.
      security: []
      parameters: [{ $ref: '#/components/parameters/SessionId' }]
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                card: { type: string, description: Test card number, default: '4242424242424242' }
      responses:
        '200':
          description: Outcome
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [success, declined] }
                  decline_code: { type: string, nullable: true }
                  session_id: { type: string }
                  order_id: { type: string, nullable: true }
                  payment_id: { type: string, nullable: true }
                  share_id: { type: string, nullable: true, description: 'Referral share id created on success.' }
                  test_mode: { type: boolean }
        '403': { description: not_a_sandbox_session, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Session not payable }
        '429': { $ref: '#/components/responses/RateLimited' }

  /status/{id}:
    get:
      summary: Poll session status
      description: Poll until `status` is `success` (or a terminal state). The webhook is the authoritative signal; polling is a fallback.
      security: []
      parameters: [{ $ref: '#/components/parameters/SessionId' }]
      responses:
        '200':
          description: Current status
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending, processing, success, failed, expired] }
                  order_id: { type: string, nullable: true, description: 'Present when status=success.' }
                  share_id: { type: string, nullable: true, description: 'Referral share id; present when status=success.' }
                  error_code:
                    type: string
                    nullable: true
                    enum: [payment_declined, session_expired]
                    description: >
                      Present on a terminal non-success state: `payment_declined`
                      (status=failed) or `session_expired` (status=expired). An
                      expired session is reported as status=expired with HTTP 200
                      here (not 410), so pollers get a terminal state.
        '404': { $ref: '#/components/responses/NotFound' }

  /customer/{id}:
    post:
      summary: Attach or complete buyer details on a session
      description: >
        Persist buyer contact/shipping details on a pending session (the `customer`
        object is optional at creation and can be completed here before payment).
        Authorized by the session id. A phone number is only stored when accompanied
        by a valid `phone_proof`; otherwise it is dropped.
      security: []
      parameters: [{ $ref: '#/components/parameters/SessionId' }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [customer]
              properties:
                customer:
                  type: object
                  properties:
                    email: { type: string, format: email }
                    phone: { type: string }
                    first_name: { type: string }
                    last_name: { type: string }
                    address_line1: { type: string }
                    address_line2: { type: string }
                    city: { type: string }
                    postal_code: { type: string }
                    country: { type: string, description: ISO 3166-1 alpha-2 }
                phone_proof: { type: string, description: Required to persist a phone number. }
      responses:
        '200': { description: 'Saved', content: { application/json: { schema: { type: object, properties: { success: { type: boolean, example: true } } } } } }
        '400': { description: customer object required, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { $ref: '#/components/responses/NotFound' }

  /refunds:
    post:
      summary: Refund a payment (full or partial)
      description: >
        Requires the `refund` scope. Idempotent via the `Idempotency-Key` header
        (bound to the request body). Omit `amount` to refund the full remaining balance.
      security: [{ apiKey: [] }]
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string }
          description: Reuse-safe key. A replay returns the stored response; reuse with a different body returns 422.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [payment_id]
              properties:
                payment_id: { type: string }
                amount: { type: number, description: 'Decimal euros, ≤2dp. Omit for full remaining.', example: 19.90 }
                reason: { type: string }
      responses:
        '200':
          description: Refund recorded
          content:
            application/json:
              schema:
                type: object
                properties:
                  refund_event_id: { type: string, format: uuid }
                  payment_id: { type: string }
                  amount: { type: number }
                  is_full_refund: { type: boolean }
                  status: { type: string, enum: [pending_provider, sandbox_dry_run] }
        '400': { description: invalid_amount, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'insufficient_scope, or environment mismatch', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '404': { description: payment_not_found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '409': { description: 'payment_not_refundable, or idempotency_key_in_progress', content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '422': { description: idempotency_key_reused (different body), content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
        '429': { $ref: '#/components/responses/RateLimited' }

webhooks:
  checkout.session.completed:
    post:
      summary: Fired when a payment completes (authoritative confirmation)
      description: |
        Signed POST to the merchant's registered endpoint. This is the
        **authoritative** confirmation — the browser never confirms a charge.

        **Verify**: `X-SocialPay-Signature: t=<unix>,v1=<hex>` where
        `v1 = HMAC-SHA256(signing_secret, t + "." + rawRequestBody)`. Recompute over
        the RAW body, reject a stale `t`, and derive the event type + idempotency
        key from the signed body (`id`, `type`), not the advisory `X-SocialPay-*`
        headers.

        **Delivery**: at-least-once, order NOT guaranteed — **dedupe on the event
        `id`**. A non-2xx (or timeout) response is retried up to 8 times with
        exponential backoff (≈ 1m, 5m, 30m, 2h, 6h, 12h, 24h), then marked dead.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/SessionCompletedEvent' }
      responses:
        '2XX': { description: Acknowledged (any 2xx). Non-2xx / timeout is retried with exponential backoff. }
  payment.refunded:
    post:
      summary: Fired when a refund is recorded
      description: |
        Signed like `checkout.session.completed` (same signature scheme, same
        retry + at-least-once semantics — dedupe on the event `id`).
        `pending_provider` is not terminal for a live refund; the settlement/failure
        signal at the acquirer is out of scope for API v1.
      requestBody:
        content:
          application/json:
            schema: { $ref: '#/components/schemas/PaymentRefundedEvent' }
      responses:
        '2XX': { description: Acknowledged }

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: 'Merchant API key: sp_live_… or sp_test_…'
  parameters:
    SessionId:
      name: id
      in: path
      required: true
      schema: { type: string, example: chk_7oztnk9kh2lvlwbv }
  responses:
    Unauthorized: { description: invalid_api_key, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    NotFound: { description: Not found, content: { application/json: { schema: { $ref: '#/components/schemas/Error' } } } }
    RateLimited:
      description: >
        `rate_limited` — too many requests for this window. The `Retry-After`
        header and `retry_after` field give the seconds to wait before retrying.
      headers:
        Retry-After: { schema: { type: integer }, description: Seconds until the window resets. }
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: rate_limited }
              message: { type: string }
              retry_after: { type: integer, example: 36 }
    ValidationError:
      description: Validation error
      content:
        application/json:
          schema:
            type: object
            properties:
              error: { type: string, example: 'Validation error' }
              details: { type: array, items: { type: string } }
  schemas:
    Error:
      type: object
      properties:
        error: { type: string }
        message: { type: string }
    CreateSessionRequest:
      type: object
      required: [order, return_url]
      properties:
        order:
          type: object
          required: [items, total]
          properties:
            items:
              type: array
              minItems: 1
              items:
                type: object
                required: [product_id, name, price, quantity]
                properties:
                  product_id: { type: string }
                  name: { type: string }
                  price: { type: number, description: 'Decimal euros, ≤2dp', example: 9.90 }
                  quantity: { type: integer, minimum: 1 }
                  product_url: { type: string, format: uri }
            total: { type: number, description: 'Charged amount. Must equal items + shipping (±0.01). Decimal euros, ≤2dp.', example: 24.70 }
            shipping:
              type: number
              default: 0
              description: >
                Shipping fee (decimal euros, ≤2dp). Charged in `total` but earns NO
                reward. Pass it here — never fold it into item prices — so cashback,
                bonuses and tier GMV are computed on the goods (items) only.
              example: 4.90
            currency: { type: string, default: EUR }
            referral_code: { type: string }
        customer:
          type: object
          description: Optional at creation; may be completed later.
          properties:
            email: { type: string, format: email }
            phone: { type: string }
            first_name: { type: string }
            last_name: { type: string }
        return_url: { type: string, format: uri }
    Session:
      type: object
      description: >
        Public session view for the hosted checkout page. Authorized by the
        session id alone, so it deliberately excludes PII beyond the buyer email
        and never exposes the merchant uuid.
      properties:
        session_id: { type: string, example: chk_7oztnk9kh2lvlwbv }
        status: { type: string, enum: [pending, processing, success, failed, expired] }
        merchant:
          type: object
          properties:
            name: { type: string, example: AMO Paris }
            logo_url: { type: string, nullable: true, format: uri }
            return_url: { type: string, nullable: true, format: uri }
        items:
          type: array
          items:
            type: object
            properties:
              product_id: { type: string }
              name: { type: string }
              price: { type: number }
              quantity: { type: integer }
              product_url: { type: string, format: uri }
        currency: { type: string, example: EUR }
        subtotal: { type: number, example: 49.90 }
        shipping: { type: number, example: 0 }
        tax: { type: number, example: 0 }
        total: { type: number, example: 49.90 }
        customer_email: { type: string, nullable: true, format: email }
        expires_at: { type: string, format: date-time }
    PaymentIntentLive:
      type: object
      properties:
        publicKey: { type: string }
        payload: { type: string }
        checksum: { type: string }
    PaymentIntentTestMode:
      type: object
      properties:
        test_mode: { type: boolean, const: true }
        simulate_url: { type: string }
        test_cards:
          type: object
          properties:
            success: { type: string }
            declined: { type: string }
            insufficient_funds: { type: string }
    WebhookEnvelope:
      type: object
      description: Common envelope for every signed webhook delivery.
      required: [id, type, created, data]
      properties:
        id: { type: string, format: uuid, description: Event id — dedupe on this (delivery is at-least-once). }
        type: { type: string, enum: [checkout.session.completed, payment.refunded, webhook.ping] }
        created: { type: integer, description: Unix seconds }
        data: { type: object }
    SessionCompletedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type: { type: string, const: checkout.session.completed }
            data:
              type: object
              properties:
                session_id: { type: string, example: chk_7oztnk9kh2lvlwbv }
                order_id: { type: string, example: ORD-9F2A1C7B4E00 }
                payment_id: { type: string, example: PAY-1A2B3C4D5E6F }
                merchant_id: { type: string, format: uuid }
                amount: { type: number, description: Charged total (goods + shipping), decimal euros., example: 54.90 }
                currency: { type: string, example: EUR }
                status: { type: string, const: success }
                livemode: { type: boolean, description: 'false for sandbox/test traffic.' }
    PaymentRefundedEvent:
      allOf:
        - $ref: '#/components/schemas/WebhookEnvelope'
        - type: object
          properties:
            type: { type: string, const: payment.refunded }
            data:
              type: object
              properties:
                payment_id: { type: string, example: PAY-1A2B3C4D5E6F }
                refund_event_id: { type: string, format: uuid }
                amount: { type: number, example: 5.00 }
                is_full_refund: { type: boolean }
                new_payment_status: { type: string, enum: [refunded, partially_refunded], example: partially_refunded }
                currency: { type: string, example: EUR }
                merchant_id: { type: string, format: uuid }
                livemode: { type: boolean }
