# Create a checkout session

Returns a checkout `url` where the customer pays without leaving Veyqo: by bank transfer, USSD or mobile money on the page itself, or by card in the gateway's own secure form over it. Created here with your secret key, the amount cannot be changed by the customer; open it with the JS SDK or redirect to `url`.

## Endpoint

POST https://api.veyqo.org/api/v1/checkout/sessions

Server secret key required. Scope: payments:write. The key determines the project and environment; no project-selection header is accepted.

> For each new operation, generate and persist one OPERATION_IDEMPOTENCY_KEY. Retry the unchanged request with that same key. Do not generate a new key on every retry.

## Example request

Examples use illustrative IDs and provider URLs. Replace path variables with IDs returned by your own requests. Never paste a live secret into client code or a public tool.

### cURL

```bash
curl --request POST "https://api.veyqo.org/api/v1/checkout/sessions" \
  --header "Authorization: Bearer $VEYQO_SECRET_KEY" \
  --header "Accept: application/json" \
  --header "Idempotency-Key: $OPERATION_IDEMPOTENCY_KEY" \
  --header "Content-Type: application/json" \
  --data '{
  "amount": 50000,
  "currency": "NGN",
  "reference": "ORDER-1001",
  "provider": "paystack",
  "customer": {
    "email": "customer@example.com"
  },
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart"
}'
```

### Node.js

```javascript
// Server-side Node.js 18+. Save operation.idempotencyKey before a write.
const response = await fetch(`https://api.veyqo.org/api/v1/checkout/sessions`, {
  method: 'POST',
  headers: {
  "Authorization": `Bearer ${process.env.VEYQO_SECRET_KEY}`,
  "Accept": "application/json",
  "Idempotency-Key": operation.idempotencyKey,
  "Content-Type": "application/json"
},
  body: JSON.stringify({
  "amount": 50000,
  "currency": "NGN",
  "reference": "ORDER-1001",
  "provider": "paystack",
  "customer": {
    "email": "customer@example.com"
  },
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart"
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
  console.error(response.status, result.error?.code, response.headers.get('X-Request-Id'));
  throw new Error(result.error?.message ?? 'Veyqo request failed');
}
console.log(result.data);
```

### PHP

```php
<?php
// Server-side PHP with the cURL extension. Persist $operationIdempotencyKey.
$curl = curl_init('https://api.veyqo.org/api/v1/checkout/sessions');
curl_setopt_array($curl, [
  CURLOPT_CUSTOMREQUEST => 'POST',
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_TIMEOUT => 30,
  CURLOPT_HTTPHEADER => [
    'Authorization: Bearer '.getenv('VEYQO_SECRET_KEY'),
    'Accept: application/json',
    'Idempotency-Key: '.$operationIdempotencyKey,
    'Content-Type: application/json'
  ],
  CURLOPT_POSTFIELDS => <<<'JSON'
{
  "amount": 50000,
  "currency": "NGN",
  "reference": "ORDER-1001",
  "provider": "paystack",
  "customer": {
    "email": "customer@example.com"
  },
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart"
}
JSON,
]);
$body = curl_exec($curl);
if ($body === false) throw new RuntimeException(curl_error($curl));
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
curl_close($curl);
$result = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if ($status >= 400 || !($result['success'] ?? false)) {
  throw new RuntimeException($result['error']['code'] ?? 'Veyqo request failed');
}
print_r($result['data']);
```

### Python

```python
# pip install requests; run on your server.
import os, requests
from urllib.parse import quote

response = requests.request('POST', f'https://api.veyqo.org/api/v1/checkout/sessions',
    headers={
  "Authorization": 'Bearer ' + os.environ['VEYQO_SECRET_KEY'],
  "Accept": "application/json",
  "Idempotency-Key": operation_idempotency_key,
  "Content-Type": "application/json"
},
    json={
  "amount": 50000,
  "currency": "NGN",
  "reference": "ORDER-1001",
  "provider": "paystack",
  "customer": {
    "email": "customer@example.com"
  },
  "success_url": "https://shop.example.com/thanks",
  "cancel_url": "https://shop.example.com/cart"
},
    timeout=30)
result = response.json()
if not response.ok or not result.get('success'):
    raise RuntimeError(f"{response.status_code} {result.get('error')} request_id={response.headers.get('X-Request-Id')}")
print(result['data'])
```

## Request body

| Field | Type | Presence | Description |
| --- | --- | --- | --- |
| amount | integer | Required | Integer minor-unit amount. NGN 500.00 is 50000; JPY 500 is 500. Minimum 1. |
| currency | string | Required | Three-letter currency supported by the selected provider. Maximum 3 characters. |
| reference | string \| null | Optional | Order reference; a session can create multiple uniquely referenced payment attempts. Maximum 100 characters. |
| description | string \| null | Optional | Maximum 500 characters. |
| customer | object | Optional | See the JSON example. |
| customer.email | string \| null | Optional | Maximum 255 characters. Format: email. |
| customer.name | string \| null | Optional | Maximum 255 characters. |
| metadata | object \| array \| null | Optional | Your non-sensitive JSON metadata. Objects are recommended; do not store secrets or card details. Maximum 20 entries. |
| provider | string \| null | Optional | Gateway selected by your server or project default; the customer chooses a payment method, not the gateway. Maximum 40 characters. |
| success_url | string \| null | Optional | HTTP or HTTPS return URL. Use HTTPS in production. Maximum 2048 characters. Format: uri. |
| cancel_url | string \| null | Optional | HTTP or HTTPS cancellation URL. Use HTTPS in production. Maximum 2048 characters. Format: uri. |

## Successful response · 201

HTTP success means the request was accepted. A pending payment or refund is not completed merely because the response is 200 or 201.

```json
{
  "success": true,
  "data": {
    "id": "cs_01M6EB66SC61Q3RQYAN0693DGM",
    "object": "checkout_session",
    "url": "https://veyqo.org/pay/cs_01M6EB66SC61Q3RQYAN0693DGM",
    "status": "open",
    "source": "api",
    "provider": "paystack",
    "amount": 50000,
    "currency": "NGN",
    "reference": "ORDER-1001",
    "description": "Order 1001",
    "customer_email": "customer@example.com",
    "payment": null,
    "success_url": "https://shop.example.com/thanks",
    "cancel_url": "https://shop.example.com/cart",
    "expires_at": "2026-11-02T13:00:00+00:00",
    "completed_at": null,
    "created_at": "2026-11-02T12:00:00+00:00"
  }
}
```

## Response fields

| Field in data | Type | Presence | Description |
| --- | --- | --- | --- |
| id | string | Required | Checkout session ID (cs_ prefix). |
| object | "checkout_session" | Required | See the JSON example. |
| url | string | Required | Hosted Veyqo URL; redirect here or open this session using VeyqoCheckout. Format: uri. |
| status | string | Required | Values: open, completed, expired. |
| source | string | Required | Values: api, browser, link. |
| provider | string | Required | Selected provider slug; this field is an object on the browser session detail endpoint. |
| amount | integer | Required | Server-defined amount in minor units. |
| currency | string | Required | Three-letter currency. |
| reference | string \| null | Required | Your order reference, when supplied. |
| description | string \| null | Required | Checkout description. |
| customer_email | string \| null | Required | Customer email if already supplied. |
| payment | object \| null | Required | Current payment attempt, if any. A session can have multiple attempts over time. |
| success_url | string \| null | Required | Configured return URL. A redirect is not proof of payment. |
| cancel_url | string \| null | Required | Configured cancellation URL. |
| expires_at | string | Required | Session expiry time. Format: date-time. |
| completed_at | string \| null | Required | ISO 8601 timestamp. Format: date-time. |
| created_at | string \| null | Required | ISO 8601 timestamp. Format: date-time. |

## Errors and recovery

| Code | HTTP | Recovery | Retry policy |
| --- | --- | --- | --- |
| INVALID_API_KEY | 401 | Use a current kc_test_ or kc_live_ secret key on /api/v1. Use a pk_test_ or pk_live_ publishable key only on browser checkout. | After fixing credentials. |
| INSUFFICIENT_PERMISSION | 403 | Grant the required scope or use a suitably scoped key. Dashboard organization roles and staff permissions are separate. | After permission changes. |
| RATE_LIMIT_EXCEEDED | 429 | Honor Retry-After, use bounded exponential backoff with jitter, and reduce concurrent requests. Read X-RateLimit headers. | Yes, after Retry-After; preserve write idempotency. |
| METHOD_NOT_ALLOWED | 405 | Use the method documented for the endpoint, such as POST for verification and refunds. | After fixing the method. |
| INTERNAL_ERROR | 500 | Record X-Request-Id, timestamp and sanitized request context. Reconcile unknown payment outcomes before retrying a write; contact support if it persists. | Safe reads: bounded backoff. Writes: reconcile first. |
| VALIDATION_FAILED | 422 | Read error.details when present, correct the named fields and retry only after correction. Validation messages may describe state/capability errors without field details. | No automatic retry. |
| IDEMPOTENCY_KEY_REUSED | 409 | Reuse the exact original request to recover that operation, or use a new key only for a genuinely different operation. | Do not retry a changed body with that key. |
| IDEMPOTENCY_IN_PROGRESS | 409 | Wait briefly, retry with the same key and unchanged request, then reconcile by reference if the result remains uncertain. | Back off with jitter; preserve key/body. |
| PLAN_LIMIT_REACHED | 402 | Review the active plan, project/usage allowances and billing status with the organization owner. | After resolving plan/billing restrictions. |

- [Complete error catalogue](https://veyqo.org/docs/errors): Every Veyqo error code, including which surface emits it and how to recover.
