Server-to-server Integration

Build a backend-driven Payrails integration that tokenizes on the frontend and your server controls the authorization steps, and webhook-first reconciliation.

The server-to-server architecture is the Payrails integration pattern where your backend owns the payment lifecycle. Your backend creates executions, calls authorize against them, handles 3-D Secure redirects, and reconciles state from webhooks. The Payrails Web SDK appears only as a thin layer on the frontend, hosting Secure Fields — the iframes that tokenize card data before the data reaches your servers.

This page documents the architecture end to end. It covers the components, the two sequence flows (frictionless and 3-D Secure), and the canonical frontend and backend code for each step.

📘

If your frontend can host the Drop-in SDK — the SDK that drives the full payment lifecycle from the client — choose that pattern instead. Use the Drop-in SDK when your frontend owns the customer session and Payrails should drive the full payment lifecycle. Use the server-to-server pattern when your backend owns order creation, fraud checks, and customer messaging, or when your tech stack cannot host a single-page application.

When to choose this pattern

Choose the server-to-server architecture when one or more of the following holds:

  • You use an e-commerce engine that relies on a synchronous auth decision. Magento, Salesforce Commerce Cloud.
  • You require control over the decision making on when to do the authorization based on backend decisions, e.g. running fraud checks, inventory checks etc. Payrails fits into that flow rather than taking ownership of it.
  • You need explicit control over the 3-D Secure challenge UX, such as iframe or modal rendering on your existing checkout page.

Architecture

This diagram shows the systems involved and the direction of data flow between them. The customer browser hosts only the Payrails Web SDK for tokenization; every other call from the browser routes through your backend.

flowchart TB
  Browser["Customer browser<br>+ Payrails Web SDK"]
  Backend["Your backend"]
  Payrails["Payrails platform<br>(API, workflow engine, Vault)"]
  PSPs["PSPs &amp; issuer ACS"]

  Browser -->|"orders"| Backend
  Backend -->|"create execution<br>authorize / poll"| Payrails
  Payrails -->|"PSP processing"| PSPs

  Browser -. "tokenize card<br>(direct via Secure Fields)" .-> Payrails
  PSPs -. "3-D Secure challenge" .-> Browser
  Payrails -. "executionActionCompleted webhook" .-> Backend

Read the diagram in two passes. The three solid arrows trace the main request path. The customer places an order against your backend. Your backend creates the execution and authorizes it against Payrails. Payrails routes the authorization through one or more Payment Service Providers (PSPs).

The three dashed arrows are the exceptions to that path. The browser tokenizes card data directly with Payrails through the Vault so the PAN never reaches your servers. The PSP routes a 3-D Secure challenge to the customer's browser when the issuer requires one. Payrails sends an executionActionCompleted webhook to your backend once the authorization resolves.

The Payrails platform handles four jobs in this architecture: card tokenization through the Vault, workflow orchestration including the PSP cascade, 3-D Secure handoff, and webhook delivery. The PSP cascade is the workflow-configured chain of providers Payrails retries the authorize against on a decline. Your backend orchestrates everything else.

Frictionless authorization flow

A frictionless authorization completes without a 3-D Secure challenge. The customer never touches a Payrails-hosted URL. This sequence diagram shows the full path from card entry to order confirmation.

sequenceDiagram
  participant Customer as Customer browser
  participant Backend as Your backend
  participant Payrails
  participant PSP as Primary PSP

  Customer->>Backend: GET /checkout
  Backend->>Payrails: POST /merchant/client/init {type:"secureFields"}
  Payrails-->>Backend: client/init payload
  Backend-->>Customer: Render checkout, boot SDK

  Note over Customer: Customer types card data<br>into Secure Fields iframes

  Customer->>Customer: Click Place order
  Customer->>Payrails: cardContainer.tokenize() via SDK
  Payrails-->>Customer: paymentInstrumentId

  Customer->>Backend: POST /place-order {paymentInstrumentId, ...}
  Backend->>Payrails: POST /executions (metadata only)
  Payrails-->>Backend: executionId
  Backend->>Payrails: POST /executions/{id}/authorize
  Payrails-->>Backend: 200 acknowledged
  Backend->>Payrails: GET /executions/{id}?waitWhile[status]=...
  Note right of Backend: Long-poll blocks until<br>the execution leaves<br>the pre-terminal states
  Payrails->>PSP: Authorize
  PSP-->>Payrails: Approved
  Payrails-->>Backend: status=authorized (poll resolves)

  Backend-->>Customer: Render order confirmation
  Payrails-->>Backend: executionActionCompleted webhook
  Backend->>Backend: Reconcile (webhook is source of truth)

3-D Secure authorization flow

The authorize call itself returns an acknowledgment. Your backend learns that a challenge is required via the executionActionPending webhook or by long-polling GET /executions/{id}. When the execution moves to status authorizePending, the state carries actionRequired: "3ds" and a requiredAction object whose href is the hosted challenge URL. Your backend returns the URL to the storefront, which renders the challenge in your preferred container — iframe, modal, new tab, or full-page redirect. Payrails returns the customer to your returnInfo.success URL once the challenge resolves.

sequenceDiagram
  participant Customer as Customer browser
  participant Backend as Your backend
  participant Payrails
  participant PSP as Primary PSP
  participant Issuer as Issuer ACS

  Customer->>Payrails: cardContainer.tokenize() via SDK
  Payrails-->>Customer: paymentInstrumentId
  Customer->>Backend: POST /place-order

  Backend->>Payrails: POST /executions (metadata)
  Payrails-->>Backend: executionId
  Backend->>Payrails: POST /executions/{id}/authorize
  Payrails-->>Backend: 200 acknowledged
  Backend->>Payrails: GET /executions/{id}?waitWhile[status]=["created","authorizeRequested"]
  Payrails->>PSP: Authorize
  PSP-->>Payrails: Requires action (3-D Secure)
  Payrails-->>Backend: status=authorizePending, requiredAction.href (poll resolves)

  Backend-->>Customer: {actionRequired:"3ds", redirectUrl}
  Customer->>Customer: Render challenge in iframe / modal / redirect
  Customer->>Issuer: Complete challenge
  Issuer-->>Payrails: Authentication result
  Payrails->>PSP: Continue authorize with cryptogram
  PSP-->>Payrails: Approved

  Payrails-->>Customer: Redirect to returnInfo.success
  Customer->>Backend: GET /payment-return/{executionId}
  Backend->>Payrails: GET /executions/{id}?waitWhile[status]=["authorizePending"]
  Payrails-->>Backend: status=authorized

  Payrails-->>Backend: executionActionCompleted webhook
  Backend->>Backend: Reconcile (webhook is source of truth)
  Backend-->>Customer: Render order confirmation

If the primary PSP declines after the challenge resolves, Payrails passes the cryptogram to the next PSP in the cascade. The challenge runs once per transaction, not once per PSP.

Frontend implementation

The frontend mounts a Payrails Web SDK element, collects a payment method, and hands a paymentInstrumentId to your backend. The element handles every PCI-scoped field. The PAN, the CVV, and the BIN never reach your servers. Render the 3-D Secure container separately; the same container is shared across every element.

Five elements support this server-to-server pattern. All five end the frontend flow with a paymentInstrumentId your backend passes to POST /executions/{id}/authorize:

  • Secure Fields — three separate iframes (number, expiry, CVV) for full layout control.
  • Card Form Element — a pre-composed card form. Faster to integrate, single mount point.
  • Card List Element — renders the customer's saved cards for repeat purchases.
  • Apple Pay Element — the Apple Pay button for wallet checkout on Safari and iOS.
  • Google Pay Element — the Google Pay button for wallet checkout.

The client/init.type setting controls which elements the SDK exposes. Use "secureFields" for Secure Fields or Card Form. Use "tokenization" for Card List and the wallet buttons. Do not use "dropIn" for this pattern — that mode lets the SDK drive authorize and bypasses your backend.

Post to your backend

Every element ends the same way: hand the paymentInstrumentId and paymentMethodCode to your backend. This shared helper is referenced by every tab in the next section:

async function postToBackend ({ paymentInstrumentId, paymentMethodCode }) {
  const response = await fetch('/checkout/place-order', {
    method:  'POST',
    headers: { 'Content-Type': 'application/json' },
    body:    JSON.stringify({
      paymentInstrumentId,
      paymentMethodCode,
      amount:         { value: '129.90', currency: 'EUR' },
      orderReference: window.currentOrderReference,
    }),
  }).then(r => r.json())

  if (response.actionRequired === '3ds' && response.redirectUrl) {
    openThreeDsChallenge(response.redirectUrl)
    return
  }
  if (response.status === 'authorized') {
    window.location.assign(`/order-confirmation/${response.orderReference}`)
    return
  }
  renderError(response)
}

The shared boot sequence is identical across elements: fetch client/init, call Payrails.init, then mount the chosen element. The SDK returns a SaveInstrumentResponse from direct tokenize calls (Secure Fields, Card Form) and a StoredPaymentInstrument from the Card List onCardChange callback. The id field on either is the value to pass as paymentInstrumentId.

Mount and tokenize

Mount three iframes (one per PCI-scoped field), call cardContainer.tokenize() on form submission, and hand the resulting id to your backend.

<form id="checkout-form">
  <label for="card-number">Card number</label>
  <div id="card-number"></div>

  <label for="card-expiry">Expiry</label>
  <div id="card-expiry"></div>

  <label for="card-cvv">CVV</label>
  <div id="card-cvv"></div>

  <button id="place-order" type="button">Place order</button>
</form>
const initOptions = await fetch('/payments/init', { method: 'POST' })
  .then(r => r.json())
const payrails = Payrails.init(initOptions)

const cardContainer = payrails.collectContainer({ containerType: 'COLLECT' })
cardContainer.createCollectElement({ type: 'CARD_NUMBER' }).mount('#card-number')
cardContainer.createCollectElement({ type: 'EXPIRATION_DATE' }).mount('#card-expiry')
cardContainer.createCollectElement({ type: 'CVV' }).mount('#card-cvv')

document.getElementById('place-order').addEventListener('click', async () => {
  const tokenized = await cardContainer.tokenize({ storeInstrument: false })
  await postToBackend({
    paymentInstrumentId: tokenized.id,
    paymentMethodCode:   'card',
  })
})

storeInstrument: false keeps the instrument ephemeral. Pass true when the customer has opted in to saving the card on the profile. Requires client/init.type: "secureFields".

Render the 3-D Secure container

The 3-D Secure container is the same across every element. When the postToBackend response carries actionRequired: "3ds" and a redirectUrl, render the URL in your chosen container — iframe, modal, new tab, or full-page redirect. The customer completes the challenge. Payrails returns them to your returnInfo.success URL. The return page posts back to the opener so the checkout page can route to the confirmation step.

<div id="threeds-modal" hidden>
  <iframe id="threeds-iframe" title="3-D Secure challenge"></iframe>
</div>
function openThreeDsChallenge (redirectUrl) {
  const iframe = document.getElementById('threeds-iframe')
  iframe.src = redirectUrl
  document.getElementById('threeds-modal').hidden = false
}

// The Payrails return page posts back to the opener once your backend
// has re-read the execution status.
window.addEventListener('message', event => {
  if (event.origin !== window.location.origin) return
  if (event.data?.type !== 'threeds:complete') return
  window.location.assign(`/order-confirmation/${event.data.orderReference}`)
})

Backend implementation

The backend authenticates with Payrails, creates the execution, calls authorize against the tokenized paymentInstrumentId, and returns a clean two-case contract to the storefront. This code uses Python and the requests library; the same shape applies to any HTTP client.

Authentication

The Payrails OAuth token endpoint returns a token valid for one hour. Cache the token in memory and refresh on expiry.

import os
import requests

PAYRAILS_HOST = os.environ['PAYRAILS_HOST']
PAYRAILS_CLIENT_ID = os.environ['PAYRAILS_CLIENT_ID']
PAYRAILS_CLIENT_SECRET = os.environ['PAYRAILS_CLIENT_SECRET']


def get_access_token() -> str:
    """Return a fresh Payrails OAuth token."""
    response = requests.post(
        f'https://{PAYRAILS_HOST}/auth/token/{PAYRAILS_CLIENT_ID}',
        headers={'x-api-key': PAYRAILS_CLIENT_SECRET},
        timeout=10,
    )
    response.raise_for_status()
    return response.json()['access_token']

Initialize the SDK session

This call returns the SDK configuration the frontend uses to boot. The storefront's /payments/init endpoint on your backend proxies the call so the OAuth token stays on the server. The body passes workflowCode, holderReference, workspaceId, and the type that matches the element your storefront is about to mount. Use "secureFields" for Secure Fields and Card Form. Use "tokenization" for Card List, Apple Pay, and Google Pay.

The response is the payload the frontend hands to Payrails.init(...) — return it from /payments/init verbatim.

def get_client_init(
    holder_reference: str,
    init_type: str,
) -> dict[str, Any]:
    """Fetch a client/init payload from Payrails for the storefront SDK."""
    token = get_access_token()
    response = requests.post(
        f'https://{PAYRAILS_HOST}/merchant/client/init',
        headers={
            'Authorization':     f'Bearer {token}',
            'Content-Type':      'application/json',
            'x-idempotency-key': str(uuid.uuid4()),
        },
        json={
            'workflowCode':    PAYRAILS_WORKFLOW_CODE,
            'holderReference': holder_reference,
            'workspaceId':     PAYRAILS_WORKSPACE_ID,
            'type':            init_type,
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()

Create the execution

The first call creates the execution and locks the metadata. The body carries merchantReference, holderReference, workspaceId, and the meta block. The body does not carry amount, paymentComposition, or initialActions — those live on the authorize call.

import uuid
from typing import Any

PAYRAILS_WORKSPACE_ID = os.environ['PAYRAILS_WORKSPACE_ID']
PAYRAILS_WORKFLOW_CODE = 'payment-acceptance'


def create_execution(
    merchant_reference: str,
    holder_reference: str,
    meta: dict[str, Any],
) -> str:
    """Create a Payrails execution with metadata only. Returns the executionId."""
    token = get_access_token()
    response = requests.post(
        f'https://{PAYRAILS_HOST}/merchant/workflows'
        f'/{PAYRAILS_WORKFLOW_CODE}/executions',
        headers={
            'Authorization':     f'Bearer {token}',
            'Content-Type':      'application/json',
            'x-idempotency-key': str(uuid.uuid4()),
        },
        json={
            'merchantReference': merchant_reference,
            'holderReference':   holder_reference,
            'workspaceId':       PAYRAILS_WORKSPACE_ID,
            'meta':              meta,
        },
        timeout=10,
    )
    response.raise_for_status()
    return response.json()['id']

Authorize the execution

The second call dispatches the authorize against the tokenized paymentInstrumentId. The HTTP response is an acknowledgment; the actual outcome (frictionless approval, 3-D Secure required, or decline) arrives via long-poll or webhook.

def authorize_execution(
    execution_id: str,
    amount: dict[str, str],
    payment_instrument_id: str,
    payment_method_code: str,
    return_url: str,
) -> dict[str, Any]:
    """Dispatch the authorize. Outcome arrives via long-poll or webhook."""
    token = get_access_token()
    response = requests.post(
        f'https://{PAYRAILS_HOST}/merchant/workflows'
        f'/{PAYRAILS_WORKFLOW_CODE}/executions/{execution_id}/authorize',
        headers={
            'Authorization':     f'Bearer {token}',
            'Content-Type':      'application/json',
            'x-idempotency-key': str(uuid.uuid4()),
        },
        json={
            'amount':     amount,
            'returnInfo': {'success': return_url},
            'paymentComposition': [{
                'paymentMethodCode':   payment_method_code,
                'integrationType':     'api',
                'amount':              amount,
                'storeInstrument':     False,
                'paymentInstrumentId': payment_instrument_id,
            }],
        },
        timeout=15,
    )
    response.raise_for_status()
    return response.json()

Storefront-facing endpoint

The endpoint the storefront calls runs the two Payrails calls, long-polls for the result, and returns a clean two-case contract. On actionRequired === "3ds", the storefront opens the 3-D Secure container; otherwise it renders the order confirmation page.

The authorize call returns an acknowledgment; the actual outcome (frictionless approval, 3-D Secure required, or decline) arrives via long-poll or webhook. This endpoint uses the long-poll inline so the storefront receives a single resolved response. The webhook still fires in parallel and remains the source of truth for order reconciliation; the Webhook reconciliation section covers the handler.

def place_order(
    payment_instrument_id: str,
    payment_method_code:   str,
    amount: dict[str, str],
    order_reference: str,
    customer_reference: str,
) -> dict[str, Any]:
    """End-to-end place-order endpoint called by the storefront."""
    execution_id = create_execution(
        merchant_reference=order_reference,
        holder_reference=customer_reference,
        meta={
            'order':    {'reference': order_reference},
            'customer': {'reference': customer_reference},
            'tags':     {'channel': 'web'},
        },
    )

    authorize_execution(
        execution_id=execution_id,
        amount=amount,
        payment_instrument_id=payment_instrument_id,
        payment_method_code=payment_method_code,
        return_url=f'https://yourdomain.example/payment-return/{execution_id}',
    )

    result = poll_execution(
        execution_id,
        wait_while=['created', 'authorizeRequested'],
    )

    if result.get('actionRequired') == '3ds':
        return {
            'actionRequired': '3ds',
            'redirectUrl':    result['requiredAction']['href'],
            'executionId':    execution_id,
        }

    return {
        'status':         result.get('status'),
        'executionId':    execution_id,
        'orderReference': order_reference,
    }

3-D Secure handling

3-D Secure runs only when the issuer requires a challenge. Your backend learns of this through one of two channels — both are valid:

  • The executionActionPending webhook. Preferred at scale. Requires a custom notify step on the workflow paused branch with eventType: "executionActionPending". The default payment-acceptance template does not emit a webhook on the 3-D Secure pause.
  • Long-polling GET /executions/{id}. Always available, no workflow configuration required. Use waitWhile[status]=["created","authorizeRequested"] to block until the execution leaves the pre-authorize states.

The authorize HTTP response itself acknowledges the call. Do not rely on it to carry actionRequired or requiredAction.href — those fields surface on the polled execution state (or the webhook body), not necessarily on the authorize response.

Once your backend has the resolved state, the canonical field to read is requiredAction.href. On a polled execution, the same URL is mirrored as links.redirect. The legacy field links["3ds"] only appears for the deprecated 3DS action type and is often empty; always read requiredAction.href instead.

The choice of rendering container — iframe, modal, new tab, or full-page redirect — is yours. The only requirements: the container loads external URLs, and returnInfo.success is HTTPS and on your own domain.

On the frictionless path, the execution moves straight to status authorized, actionRequired stays empty, and the customer never touches a Payrails-hosted URL.

🚧

Do not redirect the customer to links.consumerWait. That field carries a Payrails-hosted intermediate URL that the Drop-in SDK uses to orchestrate redirects on the customer's behalf. Redirecting to it from a server-to-server checkout routes every authorization — including the frictionless majority — through a Payrails-hosted page. The requiredAction.href pattern documented on this page produces a URL only when a challenge is actually required.

Webhook reconciliation

Your backend confirms every order on the executionActionCompleted webhook, not on the storefront event. The storefront's state is optimistic; the webhook is the source of truth.

Every webhook delivery carries an HMAC signature header. Verify the signature before processing the body; the algorithm and header name are documented at Webhooks.

import json


def handle_webhook(raw_body: bytes, signature_header: str) -> tuple[int, str]:
    """Webhook entry point. Returns (status_code, body)."""
    if not verify_payrails_signature(raw_body, signature_header):
        return 401, 'invalid signature'

    event = json.loads(raw_body)
    if event['event'] == 'executionActionCompleted':
        details = event['details']
        if details['action'] == 'authorize' and details['success']:
            mark_order_paid(
                order_reference=details['execution']['merchantReference'],
                execution_id=details['execution']['id'],
            )

    return 200, 'ok'

The handler exits with 200 as soon as the handler records the event. Anything that can fail — order-database updates, downstream messaging, analytics — runs asynchronously after the response returns. A slow handler causes Payrails to retry the delivery, which forces your idempotency logic to do extra work.

Implement verify_payrails_signature per the Webhooks documentation.

Long-poll patterns

A long-poll on GET /executions/{id}?waitWhile[status]=... blocks until the execution leaves the listed pre-terminal states. Two cases use it in this architecture:

  • Inside the place-order request, to wait for the 3-D Secure decision. Pre-terminal states: created, authorizeRequested. The poll resolves either to authorized (frictionless) or to authorizePending (3-D Secure required, requiredAction.href populated).
  • On the payment-return page after the challenge resolves, to wait for the final outcome. Pre-terminal states: authorizePending. The poll resolves to authorized or to a failure state.
import json


def poll_execution(
    execution_id: str,
    wait_while: list[str],
    timeout_seconds: int = 30,
) -> dict[str, Any]:
    """Long-poll for the execution to leave the listed pre-terminal states."""
    token = get_access_token()
    response = requests.get(
        f'https://{PAYRAILS_HOST}/merchant/workflows'
        f'/{PAYRAILS_WORKFLOW_CODE}/executions/{execution_id}',
        params={'waitWhile[status]': json.dumps(wait_while)},
        headers={'Authorization': f'Bearer {token}'},
        timeout=timeout_seconds,
    )
    response.raise_for_status()
    return response.json()

The webhook remains the source of truth for order reconciliation. The long-poll is the in-request signal so the storefront receives a single resolved response. The two run in parallel and converge on the same execution state.

Recommendations

Idempotency keys

Every write call to Payrails carries an x-idempotency-key header. The value is a fresh UUIDv4. Payrails dedupes retries against the same key, so a network-retry of the same logical call is safe.

🔑

The idempotency key must be a valid UUID. Composite strings such as order-123_attempt-1 are rejected by the Payrails API as malformed. Allocate a fresh UUIDv4 per logical operation and persist it alongside the operation so retries reuse the same key.

Metadata immutability

The meta block on the create-execution call locks once the authorize action completes. Anything that needs to surface on dashboards, webhooks, settlement reports, or PSP portals must be on the create-execution body. Treat the second call (authorize) as payment-specific only.

The meta.order.reference and meta.customer.reference fields are not updatable after authorize. If your system assigns the internal order identifier after the Payrails call, keep the join in your own database. Example: the cart identifier is known at checkout time, while the order identifier becomes available only after authorization.

Frictionless authorizations show no Payrails UX

On the frictionless path, the customer never touches a Payrails-hosted URL. The authorize response carries an empty actionRequired and your backend renders the order confirmation page directly. The 3-D Secure container only opens when actionRequired === "3ds".

The 3-D Secure handling section explains the reason to read requiredAction.href rather than links.consumerWait. Payrails populates consumerWait on every authorize response for use by SDK-driven flows; requiredAction.href exists only when a challenge is required, so frictionless paths skip Payrails-hosted UX entirely.

Next steps

  • Review the Secure Fields reference for the per-field event surface (READY, CHANGE, FOCUS, BLUR) and the styling options available on the iframes.
  • Read Webhooks for the canonical HMAC signing format and the full event catalogue.
  • See Payment status for the meaning of every status value your reconciliation logic must handle, including Unknown (which routes to manual reconciliation, not automatic retry).
  • Use the Test cards reference to exercise both the frictionless and 3-D Secure paths during testing.