Accepting Payments — How the flow works

We are a full-stack payment platform that lets you create charges with a single API call, show a secure hosted checkout to shoppers, and receive real-time status updates via webhooks.

PayForm Figure 1 – What the shopper sees.

The diagram below shows the entire life-cycle of a payment, from the moment the shopper clicks Pay to the instant you update the order in your back-office.

PayFlow Figure 2 – Server interactions (numbers match the steps above).

  1. Checkout started
    The shopper presses Pay now on your site.
  2. Create payment
    Your back-end calls POST /api/payments with the amount and currency. And get the PayForm url
  3. Hosted PayForm
    You redirect the shopper to the PayForm; the shopper selects a pay method and completes the payment.
  4. Poll if you wish
    You can retrieve the payment with GET /api/payments/:id to see whether it is pending, succeeded or failed.
  5. Webhook push
    As soon as the status becomes final we POST a webhook to the URL you supplied, signed with the token for verification.
  6. Return to your site
    We redirect the shopper back to your redirectUrl, carrying the payment ID so you can show a receipt or an error page.

With just one API call and one webhook you have everything you need to confirm the payment, fulfil the order and give the shopper a seamless experience.

Payments API

The Payments API lets you charge users in web or mobile apps. Follow the steps below and you’ll have your first payment up and running in minutes.

Preparation

  1. Get a token in the Merchant Dashboard. Keep it on your server — never expose it in frontend code.
  2. Call every endpoint over HTTPS and add the header Authorization: Bearer <your-token>
  3. (Optional) Add an Idempotency-Key header with a UUID v4. It protects you from double charges if you resend the same request. If you omit the header, the platform will generate a key for you.

Step 1 — Create a payment

Endpoint

POST /api/payments
Content-Type: application/json

Required body fields

FieldTypeExampleDescription
amountstring, decimal"10.00"Use a dot as the decimal separator.
currencystring, ISO 4217"EUR"Three capital letters.
typestring"payin"payin or payout depending on the type of payment.

Optional body fields

FieldTypePurpose
descriptionstring, max 255 charsShown to the shopper and in your dashboard.
customerobject, max 4KB{ "id": "cust_42" (required, max 50 chars), "name": "Jo" (required, max 100 chars), "email": "jon@ex.com" (max 100 chars), "phone": "+4912345" (max 20 chars) }. Additional fields are allowed.
metadataobject, max 4KBAny JSON object (max 4KB when serialized). Arrays and null values are not allowed in the root.
externalIdstring, max 255 charsYour unique identifier for this payment. Useful for mapping our payment ID to your internal ID.
redirectUrlURL, max 2KBOverrides where to return the shopper after checkout. Must be HTTPS.
webhookUrlURL, max 2KBOverrides the default webhook just for this payment. Must be HTTPS.
formOptionsobject, max 4KBOverrides the default form settings for this payment only. Used to limit the user's payment method choices. Must be an object. Example: "formOptions": { "lang": "en", "methods": ["yape-pen-p2p", "card-usd-p2p"], "navigation": false }
stateobject, max 4KBOverrides the default state and is used to select a payment method for the user. Must be an object: state: { "view": "selectPaymentMethod", "method": "payment method name" }

cURL example

curl -X POST https://example.com/api/payments \ -H "Authorization: Bearer tk_test_n4912345faza90aefk4c3awf" \ -H "Idempotency-Key: 3e3f3796-4c3a-41e2-a90a-2c0f3e20b0a1" \ -H "Content-Type: application/json" \ -d '{ "externalId": "3e3f3796-4c3a-41e2-a90a-2c0f3e20b0a1", "amount": "10.00", "currency": "EUR", "type": "payin", "description": "Order #1456", "customer": { "id": "550e8400-e29b-41d4-a716-446655440000", "name": "Customer Name" }, "metadata": { "orderId": 1456 } }'

Response 201 Created / 200 OK

{ "id": "pay_a1b2c3d4", "status": "pending", "url": "https://example.com/checkout/pay_a1b2c3d4", "externalId": "3e3f3796-4c3a-41e2-a90a-2c0f3e20b0a1", "amount": "10.00", "currency": "EUR", "type": "payin", "description": "Order #1456", "metadata": { "orderId": 1456 }, "createdAt": "2025-05-27T14:25:00Z", "updatedAt": "2025-05-27T14:25:00Z", "expiresAt": "2025-05-27T14:25:00Z" }

Fields not echoed back: redirectUrl, customer, webhookUrl.

Overrides the default state

You can manually set the initial state of a payment. This can be useful in two situations:

  1. Preselecting a payment method If you already know which payment method should be used, you can set the required state immediately, skipping the standard method-selection flow.

  2. Creating without user interaction The recipient details have already been collected. The client requests the payment instructions (where the funds should be sent).

Below is a detailed explanation of each scenario.

Creating a payment without user interaction

⚠️ Important: The state object supports the following fields: method, name, phone, account, bank, card. You should provide only the fields required by the selected payment method.

Then provide a state object, for example for yape-pen-p2p:

{ "state": { "method": "yape-pen-p2p", "name": "JOHN TEST", "phone": "+573001112233" } }

In the method field, you should pass the internal name of the payment method, for example card-usd-p2p, yape-pen-p2p, etc.

Full request example:

curl -X POST 'https://example.com/api/payments' \ -H "Authorization: Bearer tk_test_n4912345faza90aefk4c3awf" \ -H "Content-Type: application/json" \ -d '{ "amount": "10", "currency": "PEN", "type": "payin", "state": { "method": "yape-pen-p2p", "name": "JOHN TEST" } }'

If all validations are successful, the method is active, and the provided details are valid, you will receive a response like this:

{ "id": "pay_4dc6dec2-1684-41dd-b99e-d330200e132b", "externalId": null, "amount": "10", "currency": "PEN", "status": "pending", "state": { "name": "JOHN TEST", "view": "waitingForP2PTransactionAndConfirmation", "isP2P": true, "method": "yape-pen-p2p", "isPayeerApproved": true, "payeerApprovedCounter": 1 }, "formOptions": { "lang": "es", "methods": [ "nequi-p2p", "transfiya-p2p", "daviplata-p2p", "plin-p2p", "viabcp-p2p", "interbank-p2p", "bank2bank-pe-p2p", "yape-pen-p2p", "yape-bob-p2p", "qr-bob-p2p", "card-usd-p2p", "phone2phone-usd-p2p" ], "navigation": false, }, "type": "payin", "url": "http://example.com/checkout/pay_4dc6dec2-1684-41dd-b99e-d330200e132b?lang=es&methods=nequi-p2p%2Ctransfiya-p2p%2Cdaviplata-p2p%2Cplin-p2p%2Cviabcp-p2p%2Cinterbank-p2p%2Cbank2bank-pe-p2p%2Cyape-pen-p2p%2Cyape-bob-p2p%2Cqr-bob-p2p%2Ccard-usd-p2p%2Cphone2phone-usd-p2p&navigation=false", "description": "", "metadata": null }

⚠️ NOTE: Unlike the next scenario, if any validation fails in this flow, the payout will not be created at all.

Preselecting a payment method

When creating a payout, you have the option to pass a state object like this:

{ "state": { "view": "selectPaymentMethod", "method": "yape-pen-p2p" } }

In the method field, you should pass the internal name of the payment method, for example card-usd-p2p, yape-pen-p2p, etc.

The view field must always be set to "view": "selectPaymentMethod". In this case, the user will immediately see the form for entering the transfer details.

⚠️ Important: if the specified payment method is not supported by your organization, is invalid, does not support the payment currency, or the staff organizations do not have payment details for it, the payout will be created with the default state:

{ "state": { "view": "selectPaymentMethod", "method": null } }

Additionally, you can pass formOptions for the payment so that on the payment method selection step, the user only sees and can choose the methods you want. Example of a valid full JSON request body:

{ "amount": "10", "currency": "PEN", "type": "payin", "description": "Test Payment", "state": { "view": "selectPaymentMethod", "method": "yape-pen-p2p" }, "formOptions": { "navigation": false, "methods": ["yape-pen-p2p"], "lang": "es" } }

What next?

  1. Open url in the browser or WebView — the user sees the hosted checkout page.
  2. After payment we redirect the user to your redirectUrl (if you sent it) with the following query parameters: paymentId and status (succeeded/failed). Example: https://your-site.com/return?paymentId=pay_a1b2c3d4&status=succeeded.
  3. We send a webhook with the final status (succeeded / failed).
  4. Repeat the same JSON with the same or no Idempotency-Key: you’ll always get the same response, never a duplicate charge.

That’s it — two required fields, one optional header, and you’re ready to take money online.

Response with error

All API errors share the same JSON shape. Clients can rely on it for parsing, logging, and user messages.

Object shape

  • type — short machine-readable code. Examples: validationerror, authenticationerror, notfound, servererror.
  • details — first error translation key (for i18n). Keys live under i18n/messages.
  • errorId — unique ID (UUID v4). Show this to users and use it to find logs.
  • timestamp — ISO 8601 UTC timestamp of when the error occurred.
  • fieldErrors — optional. For validation errors only. Map of field → array of i18n keys.
  • variables — optional. For validation errors only. Key–value pairs injected into i18n templates.

Behavior and conventions

  • Always include: type, details, errorId, timestamp.
  • Use fieldErrors and variables only for type = validation_error.
  • details should be the primary (first) translation key for the error. For validation_error, it typically mirrors the most important field error.
  • timestamps must be UTC in ISO 8601 (e.g., 2025-05-27T14:26:12Z).
  • Never leak sensitive data in details or variables—only keys and safe values.

Validation error (single field)

{ "type": "validation_error", "details": "Errors.validation.language.notSupported", "errorId": "e2af9e0e-7d51-4e3f-a90c-37c3fdf4dcd2", "timestamp": "2025-05-27T14:26:12Z", "fieldErrors": { "language": ["Errors.validation.language.notSupported"] }, "variables": { "supportedLocales": "en-US, ru-RU, es-ES" } }

Validation error (multiple fields)

{ "type": "validation_error", "details": "Errors.validation.metadata.maxSizeBytes", "errorId": "b12345cd-6789-4ef0-8abc-1234567890ab", "timestamp": "2025-05-27T15:00:00Z", "fieldErrors": { "metadata": ["Errors.validation.metadata.maxSizeBytes"], "customer": ["Errors.validation.customer.id.required"] }, "variables": { "maxSizeBytes": "4096" } }

Notes for translators

  • Keys live in i18n/messages.
  • variables provides placeholders for messages (e.g., {maxSizeBytes}).
  • Keep messages short and actionable; avoid exposing internal codes to end users.

Below are typical failure cases you might hit while creating a payment.

HTTP codetypeWhen it happensExample details
400validation_errorRequired field is missing or fails validationErrors.validation.amount.required
401unauthorizedNo Authorization header, wrong token, or token expiredErrors.invalidToken
409idempotency_conflictSame Idempotency-Key, but body is different from the first tryErrors.payloadDiffersFromOriginalRequest
409conflictSame externalIdErrors.externalIdAlreadyInUse
429rate_limitToo many requests per minuteErrors.toManyRequests
500internalAny unexpected error on our sideErrors.generic

Example:

HTTP/1.1 400 Bad Request Content-Type: application/json { "type": "validation_error", "details": "Errors.validation.amount.required", "errorId": "8250c4ef-1d6e-4083-b4b7-35e968c8bc85", "timestamp": "2025-05-27T14:26:12Z", "fieldErrors": { "amount": ["Errors.validation.amount.required"] }, }

Use the errorId when contacting support—this lets us find the exact log entry in seconds.

Step 2 — Get the result

When the shopper finishes checkout you need the final status of the payment. Choose one (or use both for extra safety).

OptionWhen to useLatencyProCon
Webhook
(push)
Your server is publicly reachableSecondsInstant; no polling overheadYou must handle retries & verify the call
GET /api/payments/:id
(pull)
Fire-and-forget back-end jobs, or if webhooks are blocked by firewallsOn demandNo inbound traffic requiredYou decide when to poll; too often → rate-limit

Handle the webhook (recommended)

  1. Configure a default webhook URL in the Merchant Dashboard (or pass webhookUrl when you create a payment).
  2. We make an HTTPS POST with Content-Type: application/json:
    { "id": "pay_a1b2c3d4", "status": "succeeded", // or "failed" "amount": "10.00", "currency": "EUR", "type": "payin", // or "payout" "createdAt": "2025-05-27T14:25:00Z", "description": "Order #1456", "externalId": "3e3f3796-4c3a-41e2-a90a-2c0f3e20b0a1", "updatedAt": "2025-05-27T14:26:18Z", "metadata": { "orderId": 1456 } }
  3. Return 2xx within 10 s to stop retries.
  4. (Optional but strongly advised) Verify the signature in the X-Signature header with your secret key.
  5. Update your order, send e-mail receipts, ship the goods—whatever “done” means in your system.

Retry logic We retry with exponential back-off for up to 48 h until we get a 2xx. Each retry carries the same body and the same Idempotency-Key, so you can safely re-process if you treat the key as unique.

Retrieve the payment

GET /api/payments/:id Authorization: Bearer <your-token>

Curl example:

curl https://example.com/api/payments/pay_a1b2c3d4 \ -H "Authorization: Bearer sk_live_yourToken"

Response 200 OK:

{ "id": "pay_a1b2c3d4", "status": "succeeded", // pending → succeeded / failed "amount": "10.00", "currency": "EUR", "type": "payin", "description": "Order #1456", "metadata": { "orderId": 1456 }, "createdAt": "2025-05-27T14:25:00Z", "updatedAt": "2025-05-27T14:26:18Z" }
statusMeaningNext step
pendingShopper is still in checkoutWait or poll again
succeededMoney captured or authorisedFulfil the order
failedPayment declined or expiredShow error, let the shopper retry

Combine both methods: accept webhooks for real-time updates, and poll as a fallback if you never hear back after, say, 5 minutes.

That’s it — listen for one webhook or make one GET call, and you’ll always know exactly when the cash is in the bank (or not).

Other sections

  • Payout - more information about payout
  • Payform - more information about custom payment chechout page
  • Payoutform - more information about custom payout checkin page