What you’ll build

A flow that takes a customer’s card details (in sandbox: a test token), creates an EPD customer, attaches the card, and runs a single charge. The charge result is delivered both synchronously in the response and asynchronously via webhook.

Use this recipe for: one-off purchases, top-ups, donations, services billed only once.

Prerequisites

  • A sandbox API key (see Quickstart).
  • A webhook endpoint registered for order.succeeded and order.failed (see Webhooks).
  • A way to capture the card: EPD Elements in production, or for sandbox testing without a browser, Inbound Card Capture with a sandbox test card number.
  • A product to charge against: orders charge against products, not free-form amounts. Create one with POST /v1/products and save its id as $PRODUCT_ID.

Steps

Create the customer (or look up an existing one)

EPD requires a first name, last name, and a phone number on every customer (in international format with a leading + and country code, e.g. +14155551234). Email is required too.

curl https://api.epd.com/v1/customers \
  -H "Authorization: Bearer $EPD_KEY" \
  -H "epd-version: 2026-02-11" \
  -H "X-EPD-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "jane@example.com",
    "first_name": "Jane",
    "last_name": "Doe",
    "phone": "+14155551234"
  }'
Attach the payment method

Capture the card with EPD Elements and attach the resulting card_token.

curl https://api.epd.com/v1/customers/$CUSTOMER_ID/payment_methods \
  -H "Authorization: Bearer $EPD_KEY" \
  -H "epd-version: 2026-02-11" \
  -H "X-EPD-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "card_token": "cct_…", "set_as_default": true }'

The response returns the new payment method; save its id as $PM_ID for the next step.

Testing without a browser? Use Inbound Card Capture with a sandbox test card number (e.g. 4111 1111 1111 1111) instead: it captures and attaches the card server-to-server, no browser required.

Create the order

EPD creates the order and runs the charge in a single call. The response tells you whether the charge succeeded.

curl https://api.epd.com/v1/orders \
  -H "Authorization: Bearer $EPD_KEY" \
  -H "epd-version: 2026-02-11" \
  -H "X-EPD-Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customer_id": "'"$CUSTOMER_ID"'",
    "payment_method_id": "'"$PM_ID"'",
    "items": [
      { "product_id": "'"$PRODUCT_ID"'", "quantity": 1 }
    ],
    "metadata": { "internal_order_id": "ORD-1234" }
  }'
Confirm asynchronously via webhook

Listen for order.succeeded (or order.failed) on your webhook endpoint and update your own database. The webhook is the source of truth: it survives client-side disconnects and double-submits.

Do not mark the user-facing order as paid based on the synchronous API response alone. A network drop after EPD captured the charge but before your code saw the response would leave you out of sync. Always reconcile against the webhook.

What can go wrong

A decline is not an error envelope: the order comes back 201 with status: "failed" and a failure_code. A genuine gateway or environment error does return an error object.

FailureWhat you’ll seeBest response
Card declinedstatus: "failed", failure_code: "processor_declined"Show the card was declined; let the user try a different card.
Insufficient fundsstatus: "failed", failure_code: "insufficient_funds"Same as above; some users will retry on payday.
Expired cardstatus: "failed", failure_code: "expired_card"Prompt for a new card.
Gateway transient errorerror.code = gateway_error (5xx)Retry with the same X-EPD-Idempotency-Key.
Network timeout (no response)UnknownRetry with the same idempotency key: EPD dedupes.
card_token captured with a mismatched key environmenterror.code = environment_mismatchMake sure the publishable key used to capture the card and the secret key used to attach it are both sandbox or both live.