Payment Checkout & Payment Links API
Welcome to the official developer documentation for Olanzo Payment Checkout (https://checkout-dev.jirafix.net). Use this HTTP API to create hosted checkout sessions, generate 1:1 direct payment links, auto-match customer profiles, and receive real-time webhook confirmations for SINPE Móvil payments.
Active environment: Development
● Active here
Active API Base URL: http://checkoutapi-dev.jirafix.net
Hosted checkout / pay pages: https://checkout-dev.jirafix.net
All request bodies use snake_case JSON. Send your merchant secret key (sk_live_... or sk_test_...) in the Authorization: Bearer header.
Environment
You are viewing the documentation for the host that served this page. The Target Host badge and every code sample use this same environment automatically.
| Property | Value |
|---|---|
| Environment | Development |
| API Base URL | http://checkoutapi-dev.jirafix.net |
| Hosted Web (pay / checkout) | https://checkout-dev.jirafix.net |
| Status | ● Active here |
Credentials (sk_live_ / sk_test_) are issued per merchant per environment. A Dev key never works on Production. Open /docs on another host to see that environment.
1. Authentication
Authenticate server-to-server requests with your business's E-commerce secret key. In the Olanzo Portal: Create → Merchant → your business → E-commerce → API Keys → Secret Key → Copy. The same key works for payment links and checkout sessions.
Authorization: Bearer sk_test_YOUR_SECRET_KEY
Every example on this page runs on your backend. A secret key in browser or mobile-app code is readable by anyone who opens developer tools, and it can create and cancel payment links on your account. The Node.js tabs below are server-side code — they are not browser snippets.
Calling from a browser or mobile app
Have your frontend call your own backend, and let your backend call Olanzo with the secret key:
// Browser — no Olanzo key is exposed here.
const response = await fetch("/api/create-payment-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ invoice_id: "INV-2026-001" })
});
const paymentLink = await response.json();
window.location.href = paymentLink.url; // send the customer to the hosted page
Your backend then performs the request shown in the Node.js tabs below, using the secret key it holds.
Key types
| Prefix | Where it belongs | What it can do |
|---|---|---|
sk_test_… | Your server only | Create, read, update and cancel payment links and checkout sessions. |
pk_… | Safe in a browser | Read-only hosted-checkout helpers. Rejected with 403 insufficient_permissions on every payment-link route. |
Identifier formats
Identifiers are opaque — match on the prefix, never on a fixed length or a digit pattern.
| Identifier | Format | Example |
|---|---|---|
link_code | plk_ + 16 hex characters | plk_a1b2c3d4e5f60718 |
link_id | UUID | 7c1f5e02-9d3a-4b77-9a41-2f0c8e6b1d55 |
session_code | cs_ + 32 hex characters | cs_9b1deb4d3b7d41699c47c0e60807b5a1 |
session_id | UUID | 9b1deb4d-3b7d-4169-9c47-c0e60807b5a1 |
2. External Customer Auto-Matching & Creation
When creating a checkout session or payment link, you can provide customer contact information in the customer object. Olanzo evaluates customer identity before creation:
| Matching Criterion | Fields Evaluated | Behavior |
|---|---|---|
| 1. Phone Match | customer.phone + customer.country_code |
Matches by national digits (e.g. 80000000). If found, links to the existing customer. |
| 2. Email Match | customer.email |
Matches by normalized email. If found, links to the existing customer. |
| 3. Document ID Match | customer.cedula + customer.id_type |
Matches by government ID. Supported id_type: fisica, juridica, dimex, passport. |
| Auto-Creation | All supplied fields | If no match is found, a new customer profile is created automatically. |
3. Create Checkout Session
Creates a pending e-commerce checkout session and returns checkout_url (https://checkout-dev.jirafix.net/checkout/{session_code}).
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
Idempotency-Key | header | string | No | At most 64 characters. Replaying the same key with the same body returns the original object with 200 instead of creating a second one; the same key with a different body is 409 idempotency_conflict. Over-length is rejected, never silently truncated. The key is echoed back in the response header. |
Content-Type | header | string | Yes | application/json. |
amount | body | decimal | Yes | What the customer pays. Must be greater than 0, and carry no more decimal places than the currency allows — a third decimal on colones is rejected on amount, not rounded. |
currency | body | string | No | CRC or USD. Omitted falls back to the merchant’s default, then CRC. Any other value is refused rather than silently overridden. |
order_reference | body | string | No | Your own order id, shown to the customer under the Order ID. At most 256 characters. Leave it out when you have none: the session then uses its Order ID as the reference. |
description | body | string | No | Shown on the payment page. At most 2000 characters. |
success_url | body | string | No | Where the result page sends the customer after payment. Must be an absolute http/https URL — other schemes are refused so a real payment cannot be turned into an open redirect. |
failure_url | body | string | No | Back-link shown on the failed result page. Same URL rules as success_url. |
cancel_url | body | string | No | Back-link shown on the cancelled result page. Same URL rules. |
webhook_url | body | string | No | Per-session webhook destination, tried before the merchant-level setting. At most 500 characters, absolute http/https, and it must resolve to a public address — private, loopback and link-local destinations are refused. |
origin_url | body | string | No | The storefront page the customer came from. At most 500 characters. Never redirected to automatically — it only backs the manual “back to the store” link on the result page. Omitted, checkout falls back to the browser referrer, which usually yields only the origin. |
metadata | body | object | No | Your own key/values, returned on every read and delivered with the payment webhook. At most 256 KB serialized. |
customer | body | object | No | Contact details used to pre-fill the hosted page. |
customer.id | body | string | No | Your own customer identifier. At most 128 characters. |
customer.cedula | body | string | No | Identification number. Setting it locks the identification step — you are naming who pays and the payer cannot change it. Stored normalized. Must be valid for customer.id_type. |
customer.id_type | body | string | No | fisica (default), juridica, dimex or passport. |
customer.first_name | body | string | No | At most 512 characters. |
customer.last_name | body | string | No | At most 512 characters. |
customer.email | body | string | No | At most 320 characters, and validated. |
customer.phone | body | string | No | Digits only, 8–15, no country prefix and no +, spaces or dashes. |
customer.country_code | body | integer | No | Dialling prefix, 1–999. Required once customer.phone is set. |
customer_phone | body | string | No | Root-level alias of customer.phone. The nested value wins when both are sent. |
customer_phone_country_code | body | integer | No | Root-level alias of customer.country_code. The nested value wins. |
line_items | body | array | No | Cart lines, at most 100, at most 512 KB serialized. Carried, not charged — the session is charged amount and nothing here changes it. They exist because the lines are echoed on the confirmation webhook and the commerce event. |
line_items[].name | body | string | No | At most 512 characters. |
line_items[].sku | body | string | No | At most 128 characters. |
line_items[].quantity | body | integer | No | How many of this line. |
line_items[].unit_price | body | decimal | No | Price per unit, as you priced it. |
line_items[].image_url | body | string | No | Absolute https URL. At most 2048 characters. |
line_items[].category.id | body | string | No | Your taxonomy id. At most 64 characters. |
line_items[].category.name | body | string | No | Category label. At most 256 characters. |
line_items[].line_type | body | string | No | What this line is when it is not an ordinary product: shipping, service, fee, tip, gratuity, gift_card or discount. Omitted means product, so a delivery charge is recorded downstream as a charge rather than as something you sell. |
line_items[].iva_rate | body | decimal | No | The IVA percentage this line was priced at (13.00 = 13%). Carried, not applied. |
creation_source | body | string | No | Which flow started the session, for revenue reporting. Ignored when you authenticate with an API key — the session is recorded as integration, which is what it is. Sending it changes nothing about how you are billed or reported. |
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions" \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "Idempotency-Key: ORD-12345-RETRY-01" \
-H "Content-Type: application/json" \
-d '{
"amount": 25000,
"currency": "CRC",
"order_reference": "ORD-12345",
"description": "Shopping cart order",
"success_url": "https://your-store.example/thanks?session={SESSION_ID}",
"failure_url": "https://your-store.example/failed",
"cancel_url": "https://your-store.example/cart",
"customer": {
"id": "cust_9981",
"first_name": "María",
"last_name": "López",
"email": "maria.lopez@example.com",
"phone": "80000000",
"country_code": 506,
"cedula": "100000000",
"id_type": "fisica"
}
}'
const response = await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions", {
method: "POST",
headers: {
"Authorization": "Bearer sk_test_YOUR_SECRET_KEY",
"Idempotency-Key": "ORD-12345-RETRY-01",
"Content-Type": "application/json"
},
body: JSON.stringify({
amount: 25000,
currency: "CRC",
order_reference: "ORD-12345",
description: "Shopping cart order",
success_url: "https://your-store.example/thanks?session={SESSION_ID}",
failure_url: "https://your-store.example/failed",
cancel_url: "https://your-store.example/cart",
customer: {
id: "cust_9981",
first_name: "María",
last_name: "López",
email: "maria.lopez@example.com",
phone: "80000000",
country_code: 506,
cedula: "100000000",
id_type: "fisica"
}
})
});
const session = await response.json();
// Return session.checkout_url to your frontend and redirect the customer there.
return session.checkout_url;
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "sk_test_YOUR_SECRET_KEY");
client.DefaultRequestHeaders.Add("Idempotency-Key", "ORD-12345-RETRY-01");
var body = new {
amount = 25000m,
currency = "CRC",
order_reference = "ORD-12345",
description = "Shopping cart order",
success_url = "https://your-store.example/thanks?session={SESSION_ID}",
failure_url = "https://your-store.example/failed",
cancel_url = "https://your-store.example/cart",
customer = new {
id = "cust_9981",
first_name = "María",
last_name = "López",
email = "maria.lopez@example.com",
phone = "80000000",
country_code = 506,
cedula = "100000000",
id_type = "fisica"
}
};
var json = JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
var content = new StringContent(json, Encoding.UTF8, "application/json");
var resp = await client.PostAsync("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions", content);
var result = await resp.Content.ReadFromJsonAsync<JsonElement>();
var checkoutUrl = result.GetProperty("checkout_url").GetString();
// Redirect user → checkoutUrl
import requests
payload = {
"amount": 25000,
"currency": "CRC",
"order_reference": "ORD-12345",
"description": "Shopping cart order",
"success_url": "https://your-store.example/thanks?session={SESSION_ID}",
"failure_url": "https://your-store.example/failed",
"cancel_url": "https://your-store.example/cart",
"customer": {
"id": "cust_9981",
"first_name": "María",
"last_name": "López",
"email": "maria.lopez@example.com",
"phone": "80000000",
"country_code": 506,
"cedula": "100000000",
"id_type": "fisica"
}
}
resp = requests.post(
"http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions",
json=payload,
headers={
"Authorization": "Bearer sk_test_YOUR_SECRET_KEY",
"Idempotency-Key": "ORD-12345-RETRY-01"
}
)
session = resp.json()
checkout_url = session["checkout_url"]
# Redirect your user to checkout_url
Response Example (201 Created)
{
"session_id": "9b1deb4d-3b7d-4169-9c47-c0e60807b5a1",
"session_code": "cs_9b1deb4d3b7d41699c47c0e60807b5a1",
"checkout_url": "https://checkout-dev.jirafix.net/checkout/cs_9b1deb4d3b7d41699c47c0e60807b5a1?lang=es",
"expires_at": "2026-08-06T20:00:00Z",
"status": "pending",
"order_id": "PO-AB23CD45"
}
Responses
| Status | error_code | Meaning |
|---|---|---|
201 | — | Created. Returns session_id, session_code, checkout_url, expires_at, status and order_id — the Order ID Olanzo issued for this checkout (see Order ID). |
200 | — | Idempotent replay. The same Idempotency-Key and the same body returns the original session — nothing was created this time. Distinguish it from 201 by the status code, not the payload. |
400 | invalid_field | A field failed validation; field names it — amount, order_reference, webhook_url, line_items or Idempotency-Key. |
400 | invalid_json | Body was not valid JSON, or a value did not match its declared type. |
409 | idempotency_conflict | Same Idempotency-Key, different request body. |
422 | checkout_not_ready | The merchant has no SINPE Móvil number configured, so nothing could be collected. Save an 8-digit pay-to number under E-commerce checkout, or sync SINPE from the business. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
4. Get Session Details
Retrieves the full session payload for hosted checkout UI bootstrap (amount, merchant branding, prefilled customer data, SINPE info).
No API key. The session_code is the capability — knowing it is enough. That is what lets the hosted page call this straight from the customer's browser. Never send your secret key from a browser.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
session_code | path | string | Yes | The code returned by create-session. This is the capability — knowing it is what grants access, which is why no API key is needed here. |
Request Example
curl "http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1"
const resp = await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1");
const session = await resp.json();
using var client = new HttpClient();
var resp = await client.GetAsync("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1");
var json = await resp.Content.ReadAsStringAsync();
import requests
resp = requests.get("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1")
print(resp.json())
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The full session payload — amount, merchant branding, pre-filled customer data and SINPE details, plus order_reference and order_id (the Order ID; null on a session from before Order IDs were issued). |
404 | session_not_found | Unknown session code. Also returned for a session belonging to another merchant, deliberately — the two are indistinguishable from outside. |
5. Poll Session Status
Lightweight status polling. Returns session_code and status (pending, confirmed, cancelled, expired, failed). Prefer webhooks for production; use this for UI polling.
No API key. The session_code is the capability — knowing it is enough. That is what lets the hosted page poll this straight from the customer's browser. Never send your secret key from a browser.
How often to poll: every 5 seconds. That is advice, not a rule — nothing on this endpoint enforces an interval today. It is published so you have a number to build against, and so a limit can be introduced later without breaking anyone who followed it. Polling faster buys you nothing: a SINPE payment is confirmed when the bank reports the credit, which is asynchronous and outside our control (see §15). For anything server-side, prefer webhooks and poll only to break an unexpected silence.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
session_code | path | string | Yes | The code returned by create-session. This is the capability — knowing it is what grants access, which is why no API key is needed here. |
Request Example
curl "http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/status"
const resp = await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/status");
const { status } = await resp.json();
using var client = new HttpClient();
var resp = await client.GetAsync("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/status");
import requests
resp = requests.get("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/status")
print(resp.json()["status"])
Response Example
{
"session_code": "cs_9b1deb4d3b7d41699c47c0e60807b5a1",
"status": "pending"
}
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The session’s current status only — the lightweight shape meant for polling. |
404 | session_not_found | Unknown session code, or one belonging to another merchant. |
6. Cancel Session
Cancels an active or pending checkout session. Returns updated status cancelled.
No API key. The session_code is the capability — knowing it is enough. That is what lets the hosted page cancel straight from the customer's browser. Never send your secret key from a browser.
A session that does not exist, or is no longer pending, returns 400 with error_code: session_not_cancellable — not 404. Treat that as “already done” if you receive it after a successful cancel.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
session_code | path | string | Yes | The code returned by create-session. This is the capability — knowing it is what grants access, which is why no API key is needed here. |
— | body | — | No | This operation takes no body. |
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/cancel"
await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/cancel", { method: "POST" });
using var client = new HttpClient();
await client.PostAsync("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/cancel", null);
import requests
requests.post("http://checkoutapi-dev.jirafix.net/v1/payments/checkout/sessions/cs_9b1deb4d3b7d41699c47c0e60807b5a1/cancel")
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | Cancelled. Returns { "status": "cancelled" }. |
400 | session_not_cancellable | Returned for an unknown session as well as one that is no longer pending. This route does not distinguish the two: the single 400 is its existing contract, and splitting it into 404 and 409 would break callers, so it is held behind a version bump. Do not read this as proof the session exists. |
429 | rate_limit_exceeded | Too many requests. Wait for the interval in the Retry-After header. |
7. Create Payment Link
Creates a durable 1:1 payment link at https://checkout-dev.jirafix.net/pay/{link_code}. Authenticate with your secret key. The merchant is resolved from the key — no business_id needed.
Creates a customer-specific payment link for one CRC payment. Amounts are whole colones
(₡25 000 is sent as 25000, never 2500000). The link stops accepting
payment after the first confirmed payment or after expires_at. Send an
Idempotency-Key when creating links, and rely on the signed webhook or
GET /v1/payments/payment-links/{link_code} — not a browser
redirect — as the source of truth for payment confirmation.
Share the link wherever you talk to your customer. Pasted into WhatsApp, Microsoft Teams, Telegram,
Slack, iMessage or Facebook Messenger, it shows a preview with your business name, the amount still
to pay and an image, in the link's language — or that the link is already paid, expired or
cancelled. The preview never includes the customer's details, the reference or the
description.
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
reference | string | ✅ Yes | Invoice or order number. Unique per merchant, forever — see Idempotency. ≤30 chars. |
additional_reference | string | No | Secondary reference (PO / internal id). ≤40 chars. Not shown to the customer. |
amount_net | number | Cond. | Net colones before IVA. See Amounts & IVA. Exactly one of amount_net, amount_total or line_items. |
amount_total | number | Cond. | Gross colones including IVA, if your system stores totals. Net is derived from it. |
currency | string | No | Must be CRC. Any other value is rejected with 400 invalid_field. |
iva_rate | number | No | IVA % (0–99.99, ≤2 decimals). null = no tax line. 0 = explicitly exempt. 13 = CR standard. |
line_items | array | No | Cart lines, each with its own optional iva_rate — the way to mix tax rates in one payment. See Amounts & IVA. |
line_items[].line_type | string | No | What the line is, when it is not an ordinary product: product (default), service, fee, shipping, tip, gratuity, gift_card, discount. Carried onto the order, so a delivery or service charge is recorded as a charge instead of appearing in the merchant's product reporting. Anything else is rejected rather than treated as a product. discount is subtracted from the link total — see Amounts & IVA. |
description | string | No | Shown on the payment page. ≤140 chars. |
statement_description | string | No | Short customer-visible payment concept. ≤140 chars. |
expires_at | ISO 8601 | No | UTC expiry, at least 5 minutes ahead. Omit for a link that never expires. |
reusable | boolean | No | Default false — single-use. true keeps the link payable after the first payment. |
max_payments | integer | No | Cap for a reusable link. Requires reusable: true when above 1. |
allow_partial_payment | boolean | No | Default false. true lets the customer pay in instalments — see Paying in instalments. |
minimum_payment_amount | number | No | Smallest single instalment. Requires allow_partial_payment: true and must not exceed the total. |
allowed_payment_methods | array | No | Only ["sinpe_mobile"] is supported. Anything else is rejected rather than ignored. |
locale | string | No | BCP-47 tag, e.g. es-CR or en-US. Sets the hosted page language; defaults to your business setting. |
success_url | string | No | Where the customer lands after paying. return_url is an accepted alias. Must be absolute http/https. |
failure_url / cancel_url | string | No | Same rules. Default to your business settings, then the hosted result page. |
webhook_url | string | No | Overrides your business webhook URL for payments against this link only. Must be https:// and publicly reachable. |
metadata | object | No | Your own key/value data. Returned on every read and delivered with the payment webhook. ≤256 KB serialized. |
custom_fields | array | No | Your own questions, shown on the payment page — see Collecting your own details. Up to 5. |
require_order_confirmation | boolean | No | Default false. true makes the customer type your order number before the payment page will open — see Confirming before paying. Not allowed on a reusable link. |
require_cedula | boolean | No | Default false. true makes the customer supply their identification number in the same step — unless you already sent it as customer.cedula, in which case there is nothing to ask: the payment page opens with it filled in and this step is skipped. Not allowed on a reusable link. |
notify.events | array | No | Which payment_link.* events this link pushes. Omit for all of them; send [] for none. payment.confirmed is not accepted here — it is configured on your business. |
customer.id | string | No | Your customer identifier — Olanzo exposes no internal customer id here. customer.external_id is an alias. ≤128 chars. |
customer.type | string | No | individual (default) or business. |
customer.first_name | string | No | Send it when you know it; omit it when you do not, and never send a placeholder — a blank name leaves the field for the customer to fill in on the payment page, an invented one is indistinguishable from a real one everywhere afterwards. For a business, send business_name instead. The customer block must carry at least one of first_name, business_name, phone, a valid email, or id. |
customer.business_name | string | Cond. | Required when customer.type is business. ≤300 chars. |
customer.last_name | string | No | Customer last name. |
customer.email | string | Cond. | Used for customer auto-matching. Required when send.channels includes email. |
customer.phone | string | Cond. | Digits-only national number, no prefix. Requires country_code. Required when send.channels includes sms. |
customer.country_code | integer | Cond. | Dialling prefix (e.g. 506). Required when phone is set, and when send.channels includes sms. |
customer.cedula | string | No | National ID. Pre-fills and locks the identification step on the payment page. |
customer.id_type | string | No | fisica, juridica, dimex or passport. Inferred from the document when omitted; rejected only if it contradicts what you sent. |
customer.billing_address | object | No | line1, line2, city, province, postal_code, country. Stored and sent with the webhook; never shown to the customer. |
send.channels | array | No | email and/or sms. Omit send entirely and nothing is sent — every integration written before this still behaves the same. At least one channel is required when the object is present. See Sending the link. |
send.reminders | array | No | At most 3. Each item is either days_before_expiry or days_after_send (a positive integer) — not both, not neither. Reminders are at least 24 hours after the first send, at least 24 hours apart, and never on or after expiry. |
Request Headers
| Header | Required | Description |
|---|---|---|
Authorization | ✅ Yes | Bearer sk_test_…. A pk_ key returns 403 insufficient_permissions. |
Content-Type | ✅ Yes | application/json |
Idempotency-Key | No | ≤64 characters. Makes a retried create safe — see Idempotency. |
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links" \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"reference": "INV-2025-001",
"additional_reference": "PO-88234",
"amount_net": 25000,
"iva_rate": 13,
"description": "Monthly subscription — August 2026",
"expires_at": "2026-11-01T20:40:00Z",
"customer": {
"first_name": "María",
"last_name": "López",
"email": "maria.lopez@example.com",
"phone": "80000000",
"country_code": 506,
"cedula": "100000000",
"id_type": "fisica"
}
}'
const response = await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links", {
method: "POST",
headers: {
"Authorization": "Bearer sk_test_YOUR_SECRET_KEY",
"Content-Type": "application/json"
},
body: JSON.stringify({
reference: "INV-2025-001",
additional_reference: "PO-88234",
amount_net: 25000,
iva_rate: 13,
description: "Monthly subscription — August 2026",
expires_at: "2026-11-01T20:40:00Z",
customer: {
first_name: "María",
last_name: "López",
email: "maria.lopez@example.com",
phone: "80000000",
country_code: 506,
cedula: "100000000",
id_type: "fisica"
}
})
});
const link = await response.json();
console.log(link.url); // https://checkout-dev.jirafix.net/pay/plk_a1b2c3d4e5f60718
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "sk_test_YOUR_SECRET_KEY");
var body = new {
reference = "INV-2025-001",
additional_reference = "PO-88234",
amount_net = 25000m,
iva_rate = 13m,
description = "Monthly subscription — August 2026",
expires_at = "2026-11-01T20:40:00Z",
customer = new {
first_name = "María",
last_name = "López",
email = "maria.lopez@example.com",
phone = "80000000",
country_code = 506,
cedula = "100000000",
id_type = "fisica"
}
};
var json = JsonSerializer.Serialize(body, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower });
var content = new StringContent(json, Encoding.UTF8, "application/json");
var resp = await client.PostAsync("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links", content);
var result = await resp.Content.ReadAsStringAsync();
import requests
payload = {
"reference": "INV-2025-001",
"additional_reference": "PO-88234",
"amount_net": 25000,
"iva_rate": 13,
"description": "Monthly subscription — August 2026",
"expires_at": "2026-11-01T20:40:00Z",
"customer": {
"first_name": "María",
"last_name": "López",
"email": "maria.lopez@example.com",
"phone": "80000000",
"country_code": 506,
"cedula": "100000000",
"id_type": "fisica"
}
}
resp = requests.post(
"http://checkoutapi-dev.jirafix.net/v1/payments/payment-links",
json=payload,
headers={"Authorization": "Bearer sk_test_YOUR_SECRET_KEY"}
)
print(resp.json()["url"])
Response Example (201 Created)
The same object is returned by create, get, list, update and cancel — one shape to parse.
{
"link_id": "7c1f5e02-9d3a-4b77-9a41-2f0c8e6b1d55",
"link_code": "plk_a1b2c3d4e5f60718",
"url": "https://checkout-dev.jirafix.net/pay/plk_a1b2c3d4e5f60718?lang=es",
"reference": "INV-2025-001",
"order_id": "PL-AB23CD45",
"additional_reference": "PO-88234",
"description": "Monthly subscription — August 2026",
"statement_description": null,
"amount_net": 25000,
"iva_rate": 13,
"iva_amount": 3250,
"amount_total": 28250,
"currency": "CRC",
"status": "unpaid",
"is_test_mode": true,
"reusable": false,
"max_payments": null,
"allowed_payment_methods": ["sinpe_mobile"],
"locale": "es-CR",
"success_url": null,
"failure_url": null,
"cancel_url": null,
"webhook_url": null,
"notify": { "events": null },
"customer": {
"id": "cust_9981",
"type": "individual",
"first_name": "María",
"last_name": "López",
"business_name": null,
"email": "maria.lopez@example.com",
"phone": "80000000",
"country_code": 506,
"cedula": "100000000",
"id_type": "fisica"
},
"metadata": { "invoice_id": "inv_8492", "source": "erp" },
"expires_at": "2026-08-12T20:40:00Z",
"created_at": "2026-08-05T15:10:00Z",
"paid_at": null,
"sinpe_ref": null,
"transaction_id": null,
"cancelled_at": null
}
Responses
| Status | error_code | Meaning |
|---|---|---|
201 | — | Created. Returns the link, including link_code and the url to give the customer. |
200 | — | Idempotent replay. The same Idempotency-Key returns the original link; nothing was created. Any send block is re-queued, so a crash between the insert and the send ledger cannot leave the documented retry with an empty sends. |
400 | invalid_field | A field failed validation; field names it. |
400 | invalid_json | Body was not valid JSON, or a value did not match its declared type. |
404 | payment_link_not_found | The key resolved to no active merchant. |
409 | reference_conflict | You already have a link with this reference. Retrieve it with GET …?reference= rather than retrying. |
409 | idempotency_conflict | Same Idempotency-Key, different request body. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
8. Amounts & IVA
Money is the easiest thing to get wrong, so these rules are exact.
| Rule | Detail |
|---|---|
| Unit | Whole colones, not céntimos. ₡25 000 is 25000. There is no ×100 minor-unit convention. |
| Type | A JSON number. Decimals are accepted but limited to 2 places; a third is rejected with 400 invalid_field rather than silently rounded. |
| Minimum | Greater than 0. |
| Maximum | 99999999.99 per link. Your customer's own bank sets the per-transfer SINPE limit, which is usually far lower — Olanzo does not control it. |
| Currency | CRC only. |
| Tax formula | iva_amount = round(amount_net × iva_rate ÷ 100) to whole colones, half-up away from zero. amount_total = amount_net + iva_amount. |
iva_rate omitted | No tax line: iva_amount is 0 and amount_total equals amount_net. Identical outcome to iva_rate: 0; the difference is only that 0 records an explicit exemption. |
| Sending a total instead | Send amount_total and net is derived as amount_total ÷ (1 + iva_rate/100), with tax as the remainder — so the customer is charged exactly the total you sent, never a colón more. |
| Mixed tax rates | Use line_items, each with its own iva_rate. Tax is rounded per line and then summed. amount_net / amount_total must be omitted — the lines are authoritative. |
| Discounts | A line with line_type: "discount" is subtracted, and its iva_rate is subtracted with it. Send it as a positive unit_price — negative prices are rejected on every line — so the type is what carries the direction. Every other line_type adds, including gift_card, which here means a card being sold rather than redeemed. |
| Discount too large | The basket must still come to more than 0. If discounts cancel or exceed the charges the request is rejected with 400 invalid_field on line_items, rather than creating a link for nothing. |
Worked example — ₡25 000 net at 13%:
iva_amount = round(25000 × 13 ÷ 100) = round(3250.00) = 3250 amount_total = 25000 + 3250 = 28250
Mixed rates via line_items:
"line_items": [
{ "name": "Consulting hours", "quantity": 10, "unit_price": 2000, "iva_rate": 13 },
{ "name": "Medical supplies", "quantity": 1, "unit_price": 5000, "iva_rate": 0 }
]
// line 1: net 20000, iva round(20000 × 13 ÷ 100) = 2600
// line 2: net 5000, iva 0
// amount_net 25000, iva_amount 2600, amount_total 27600
A discount, sent as a positive price on a discount line:
"line_items": [
{ "name": "Consulting hours", "quantity": 10, "unit_price": 2000, "iva_rate": 13 },
{ "name": "Launch promotion", "quantity": 1, "unit_price": 3000, "iva_rate": 13,
"line_type": "discount" }
]
// line 1: net 20000, iva round(20000 × 13 ÷ 100) = 2600 ADDED
// line 2: net 3000, iva round( 3000 × 13 ÷ 100) = 390 SUBTRACTED
// amount_net 17000, iva_amount 2210, amount_total 19210
9. Get a Payment Link
The authoritative answer to “was this paid?”. Returns the full link object shown above,
including paid_at, sinpe_ref and transaction_id once a
payment has been matched. A link belonging to another merchant returns 404, the
same as an unknown code.
Request Example
curl "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718" \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY"
const resp = await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718", {
headers: { "Authorization": "Bearer sk_test_YOUR_SECRET_KEY" }
});
const link = await resp.json();
if (link.status === "paid") {
// Settle the order. paid_at / sinpe_ref identify the bank credit.
}
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", "sk_test_YOUR_SECRET_KEY");
var resp = await client.GetAsync("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718");
import requests
resp = requests.get(
"http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718",
headers={"Authorization": "Bearer sk_test_YOUR_SECRET_KEY"}
)
print(resp.json()["status"])
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
link_code | path | string | Yes | The link’s code, 8–24 characters. Anything outside that range is treated as unknown. |
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The link, including status, amount_paid and amount_remaining. status is computed at read time, so a link past its expiry reads expired without anything having run. |
404 | payment_link_not_found | Unknown link code. Also returned for a link belonging to another merchant, deliberately. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
10. List Payment Links
Lists your links, newest first. Filtering by reference is how you recover a link
after a 409 reference_conflict — you cannot reuse the reference, so retrieve what
you already created instead.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
reference | query | string | No | Exact match on your invoice/order reference. |
status | query | string | No | unpaid, partially_paid, paid, expired, cancelled or all. Anything else is 400 invalid_field. |
created_after | query | date-time | No | ISO 8601 UTC lower bound on creation time. |
created_before | query | date-time | No | ISO 8601 UTC upper bound on creation time. |
page | query | integer | No | Defaults to 1. A value below 1 is treated as 1 rather than refused. |
page_size | query | integer | No | Defaults to 25. Above 100 it is silently reduced to 100, not refused — read the page_size that comes back rather than assuming the one you asked for. |
Request Example
curl "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links?reference=INV-2025-001" \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY"
const url = "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links?status=unpaid&page_size=50";
const resp = await fetch(url, {
headers: { "Authorization": "Bearer sk_test_YOUR_SECRET_KEY" }
});
const { data, total_count } = await resp.json();
Response Example (200 OK)
{
"data": [ { "link_id": "…", "link_code": "plk_a1b2c3d4e5f60718", "status": "unpaid", "…": "…" } ],
"total_count": 1,
"page": 1,
"page_size": 25,
"request_id": "req_71d11cfe"
}
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | A page of links, newest first, with total_count, page and page_size. page_size above 100 is silently reduced to 100 and page below 1 is treated as 1 — neither is an error, so read the page_size you get back rather than assuming the one you asked for. |
400 | invalid_field | status was not one of unpaid, partially_paid, paid, expired, cancelled or all. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
11. Update a Payment Link
Amends an unpaid link. Anything else returns 409 not_editable.
An incoming SINPE transfer is matched to a link by its reference and its exact amount. Changing either after the customer has the URL would break that match and strand their payment. Cancel the link and create a new one instead.
Send only the fields you want to change. A field you omit is left alone; an
empty string clears it. Expiry has an explicit
clear_expires_at: true because an empty string cannot express a date.
Editable: description, statement_description,
additional_reference, expires_at, locale,
success_url, failure_url, cancel_url,
webhook_url, metadata.
Request Example
curl -X PATCH "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718" \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"description": "Monthly subscription — September 2026",
"expires_at": "2026-11-01T20:40:00Z"
}'
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
link_code | path | string | Yes | The link’s code, 8–24 characters. Anything outside that range is treated as unknown. |
Content-Type | header | string | Yes | application/json. |
description | body | string | No | Replaces the description shown on the payment page. |
statement_description | body | string | No | Short customer-visible payment concept, separate from description. |
additional_reference | body | string | No | Secondary reference, such as a PO number. |
expires_at | body | date-time | No | New absolute UTC expiry, and it must still be at least 5 minutes out. Ignored when clear_expires_at is true. |
clear_expires_at | body | boolean | No | true removes the expiry entirely — the link stays payable until paid or cancelled. |
locale | body | string | No | Checkout language as a BCP-47 tag, e.g. es-CR. |
return_url | body | string | No | Where the customer lands after paying. Alias of success_url; return_url wins when both are sent. |
success_url | body | string | No | See return_url. |
failure_url | body | string | No | Back-link shown on the failed result page. |
cancel_url | body | string | No | Back-link shown on the cancelled result page. |
webhook_url | body | string | No | Webhook destination for payments against this link. |
metadata | body | object | No | Replaces the stored metadata wholesale — this is not a merge. Send the whole object you want to keep, or you will lose the keys you left out. |
require_cedula | body | boolean | No | Whether the hosted page asks for the payer’s identification. Omitted or null leaves it unchanged, which is the convention for every field on this body — only an explicit true/false changes it. |
require_order_confirmation | body | boolean | No | Confirm-before-paying gate. Same null-means-unchanged rule. |
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The amended link. |
400 | invalid_field | A field failed validation; field names it. |
404 | payment_link_not_found | Unknown link code, or one belonging to another merchant. |
409 | not_editable | The link is no longer unpaid, so it cannot be amended. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
12. Cancel a Payment Link
Stops the link accepting payment and returns the updated link object. Only an unpaid link can
be cancelled — a paid, expired or already-cancelled one returns 409 not_cancellable.
If the customer's payment is confirmed at the same moment, the payment wins:
the cancel is refused rather than leaving you with a paid link marked cancelled.
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/cancel" \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY"
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
link_code | path | string | Yes | The link’s code, 8–24 characters. Anything outside that range is treated as unknown. |
— | body | — | No | This operation takes no body. |
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The cancelled link. It stops accepting payment immediately. |
404 | payment_link_not_found | Unknown link code, or one belonging to another merchant. |
409 | not_cancellable | The link is no longer unpaid, so it cannot be cancelled. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
13. List a Link's Payment Attempts
Every payment attempt against the link, newest first. A new attempt is recorded each time the
customer opens the link and a fresh checkout session is created; exactly one of them can
reach confirmed.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
link_code | path | string | Yes | The link’s code, 8–24 characters. Anything outside that range is treated as unknown. |
Request Example
curl "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/payments" \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY"
const resp = await fetch(
"http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/payments",
{ headers: { "Authorization": "Bearer sk_test_YOUR_SECRET_KEY" } }
);
const { data } = await resp.json();
// Exactly one attempt can be "confirmed"; the rest are abandoned or expired sessions.
const settled = data.find(a => a.status === "confirmed");
Response Example (200 OK)
{
"link_code": "plk_a1b2c3d4e5f60718",
"data": [
{
"session_code": "cs_9b1deb4d3b7d41699c47c0e60807b5a1",
"status": "confirmed",
"amount": 28250,
"currency": "CRC",
"created_at": "2026-08-05T19:58:00Z",
"confirmed_at": "2026-08-05T20:10:00Z",
"expires_at": "2026-08-05T20:28:00Z"
}
],
"request_id": "req_71d11cfe"
}
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | Every payment attempt recorded against the link, settled or not. A link paid in instalments has one entry per instalment. |
404 | payment_link_not_found | Unknown link code, or one belonging to another merchant. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
14. Resolve Payment Link → Session
Public capability endpoint used by hosted 1:1 payment link pages (/pay/{linkCode}) to resolve or mint an active checkout session. The link code is the capability token — no API key required.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
linkCode | path | string | Yes | The link’s code, 8–24 characters. No API key — the code is the capability, which is what lets the hosted page call this from the customer’s browser. |
amount | body | decimal | No | How much to pay now. Omit it for the whole remaining balance, which is what every link did before instalments existed — an existing caller that sends no body is unaffected. Only meaningful on a link created with allow_partial_payment. |
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/session"
const resp = await fetch("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/session", {
method: "POST"
});
const { session_code, checkout_url } = await resp.json();
using var client = new HttpClient();
var resp = await client.PostAsync("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/session", null);
var json = await resp.Content.ReadAsStringAsync();
import requests
resp = requests.post("http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/session")
print(resp.json())
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The checkout session for this link — minted, or the open one reused. Opening a link twice gives the same session, not a second one. |
400 | invalid_field | The amount was not acceptable for this link; field names it. |
404 | payment_link_not_found | Unknown link code. |
429 | rate_limit_exceeded | Too many requests. Wait for the interval in the Retry-After header. |
Confirming before paying
The API below is live and the gate is enforced server-side, but the hosted
/pay/{link_code} page does not render the confirmation step yet. A link
created with these flags today cannot be completed by a customer on the hosted
page — only by an integrator who builds the confirm step into their own page.
Leave both flags off for hosted-page links until this section says otherwise.
These two flags make the customer answer something before a payment page will open. They do different jobs, and it is worth being precise about which:
| Flag | What it actually does |
|---|---|
require_order_confirmation | Verifies. The customer types your order number and it is compared server-side against the link's own reference, its order_id or its additional_reference. A wrong answer never opens the page. This is what makes a forwarded link useless to someone who does not know the order. |
require_cedula | Collects. The customer's identification number is checked for valid format and stored with the payment — it is not compared against a cédula you supplied, so on its own it does not prove who the payer is. Use it to capture the identification, and pair it with require_order_confirmation when you need the link itself protected. Because it collects rather than verifies, it asks nothing when you already sent customer.cedula: there is no question left, so the step is skipped and the payment page opens with the identification filled in (the customer can still change it). require_cedula on a link read keeps reporting the flag you set; the resolve response below reports what the link will actually ask. |
While the gate is unpassed, resolving the link returns state
confirmation_required and no session is minted. The response also
withholds the order number itself — otherwise the page would display the very answer the
customer is being asked for. For the same reason, while
require_order_confirmation is unpassed the description is withheld
too whenever it happens to contain the order number; a description that does not is returned
unchanged.
The resolve response's require_order_confirmation and require_cedula
are the effective gate — what this link will actually ask this customer.
Render your confirm step from those two, not from the flags on a link read: a link created
with require_cedula and a customer.cedula resolves with
require_cedula: false, and a cedula posted to
/confirm for such a link is ignored rather than rejected.
Public capability endpoint — the link code is the token, no API key required.
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
order_number | string | Cond. | Required when the link was created with require_order_confirmation. Compared server-side against the link's own reference, Order ID or additional reference. |
cedula | string | Cond. | Required when the link was created with require_cedula and the link carries no identification of its own. Do not decide this from the stored flag: read require_cedula on the resolve response (POST /v1/payments/payment-links/{link_code}/session), which reports what this link will actually ask for — it is false when customer.cedula was supplied at create, and a value sent anyway is ignored. Cédula física, jurídica, DIMEX or passport; separators are ignored. |
id_type | string | No | Identification type. Inferred from the number's own format when omitted. |
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/confirm" \
-H "Content-Type: application/json" \
-d '{"order_number":"INV-2026-0142","cedula":"1-1234-5678"}'
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | Confirmed — or, when the link is already paid, expired or cancelled, the link’s state payload so the page can render that view instead of a confirmation form. Check the payload, not the status code. |
400 | invalid_field | The identification number was not a valid cédula física, jurídica, DIMEX or passport. field is cedula. |
400 | invalid_field | The confirmation was wrong. The message is deliberately neutral and identical every time — it never reveals whether the order number exists, so the endpoint cannot be used to enumerate orders. |
403 | confirmation_locked | Ten wrong confirmations on this link, and the gate stops evaluating guesses. Attempts are counted before the guess is evaluated, so the cap cannot be evaded by racing. The link is not cancelled — the customer contacts the business. |
404 | payment_link_not_found | Unknown link code. |
429 | rate_limit_exceeded | Too many requests. Wait for the interval in the Retry-After header. |
| Outcome | What you get |
|---|---|
| Correct | 200 with the link state — the payment page opens and a session can now be minted. A confirmed cédula is carried onto the session, so the customer is not asked twice. |
| Wrong | 400 with a deliberately neutral message, identical every time. It never reveals whether the order number exists, so the endpoint cannot be used to discover other orders. |
| Malformed identification | 400 invalid_field pointing at cedula — format is checked before anything is compared. |
| Too many attempts | 403 confirmation_locked after ten wrong confirmations on the link. Counted before the guess is evaluated, so the cap cannot be evaded by racing. The link is not cancelled — the customer contacts the business. |
| Already paid, expired or cancelled | 200 with that state, so the page shows the right view instead of a confirmation form. |
The number is published rather than kept quiet: the per-IP rate limit is what actually slows an attacker, and a merchant who cannot tell a customer how many tries they have left ends up guessing on a support call. Three details decide whether a real customer ever meets it:
- Ten wrong guesses per link, not per customer, per session or per day.
- A malformed identification number does not spend an attempt. Format is checked before anything is compared — a typo is not a guess. Only a well-formed answer that turns out to be wrong counts.
- The count resets to zero on a successful confirmation, and again when you change the confirmation requirement on an already-confirmed link through §11 — that re-opens the gate, so the payer is asked again with a full ten. Changing the requirement on a link that has never been confirmed does not reset it: a link already locked on ten wrong order numbers stays locked even if you swap that requirement for a cédula question. To give a locked customer a fresh gate, cancel the link and issue a new one.
An unconfirmed link will not hand out payment vectors — QR, Olanzo Pay and the test-mode confirmation all refuse while the gate is unpassed. There is no route to paying that skips it.
Sending the link
A payment link does not email or text the customer unless you ask. Add an optional
send block on create, or call the send endpoint later. The destination is
always the customer on this link — we never look up another address from CRM or
the bank.
| What you send | What happens |
|---|---|
No send object | Nothing is queued. Create, get and list look exactly as they did before this existed. |
send.channels: ["email"] | Requires customer.email. Missing it is 400 invalid_field on customer.email — never a silent skip. |
send.channels: ["sms"] | Requires customer.phone and customer.country_code. Same named-field 400 if either is missing. |
days_before_expiry with no expires_at | 400 on that reminder field. The time cannot be computed, so it is not accepted. |
| A test-mode secret key | The ledger records skipped with skip_reason: test_mode. No message leaves the account. |
| Paid, cancelled or expired at send time | The attempt is recorded skipped with a reason. Reminders are checked when they fire, not when they were scheduled — a reminder after a payment does not go out. |
Create never waits on the outbound send. The first attempt is queued as pending
and a background sweep delivers it. Reminders sit on the same ledger until their time.
Create and get echo a sends array once anything was queued; list omits it when
empty.
Queue a send (and optional reminders) for an existing link. {linkCode} also
accepts the stable link_id GUID. Same send body as create.
A secret key is required. Each call is a new send; pass the same
Idempotency-Key header to retry a call that may have timed out
without duplicating it.
Request
| Name | In | Type | Required | Description |
|---|---|---|---|---|
Authorization | header | string | Yes | Bearer sk_… — your secret key. A publishable pk_ key is refused with 403. |
linkCode | path | string | Yes | The link’s code, 8–24 characters. |
Idempotency-Key | header | string | No | Identifies this send attempt. Every call is a new send — pass the same key to retry a call that may have timed out without delivering twice. Omitted, one is generated, so a blind retry does send again. |
Content-Type | header | string | Yes | application/json. |
channels | body | array | Yes | email and/or sms. At least one is required. |
reminders | body | array | No | At most 3, at least 24 hours apart, and never after the link expires. |
reminders[].days_before_expiry | body | integer | No | Send this reminder that many days before expiry. Each reminder carries exactly one of this and days_after_send — not both, not neither. |
reminders[].days_after_send | body | integer | No | Send this reminder that many days after the first send. See the rule above. |
Request Example
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/payment-links/plk_a1b2c3d4e5f60718/send" \
-H "Authorization: Bearer sk_test_YOUR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"channels":["email","sms"],"reminders":[{"days_before_expiry":3}]}'
Responses
| Status | error_code | Meaning |
|---|---|---|
200 | — | The link, with the queued sends on it. Queued, not delivered — delivery outcome arrives separately. |
400 | invalid_field | No channel was given, or a reminder broke one of the rules above; field names it. |
404 | payment_link_not_found | Unknown link code, or one belonging to another merchant. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
429 | rate_limit_exceeded | Too many requests for this key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
At most three reminders, at least 24 hours after the first send, at least 24 hours apart, and never on or after the link expiry. WhatsApp and push are not available on this API.
15. How a SINPE payment reaches a link
This is the part that differs most from a card gateway, so it is worth reading before you write any code.
| Question | Answer |
|---|---|
| Which methods can a link accept? | SINPE Móvil only. There are no cards. allowed_payment_methods accepts ["sinpe_mobile"]. |
| Does the page just show a phone number? | No. It shows a SINPE QR and an Olanzo Pay option; the customer pays from their own banking app or the Olanzo app. |
| Is the amount fixed? | Yes — the exact amount_total, unless you created the link with allow_partial_payment, in which case the customer picks a figure and that becomes the exact amount. |
| Is the payer's phone number required? | Not by this API. It comes from whichever account actually sends the transfer. |
| Is the payer's cédula required? | Collected on the payment page, not through this API. Supplying customer.cedula at create pre-fills and locks that step. |
| How is the transfer matched to the link? | The bank credit is matched on the receiving SINPE number plus the exact amount, within the payment window, and cross-checked against the reference we put on the request. That is why the amount and the reference are immutable once the link exists. |
| What if the customer transfers the wrong amount? | It does not auto-confirm and it is never silently applied to the link. The credit is recorded for manual review, the link stays payable, and you will not get a payment.confirmed for it. Reconcile these from your own records — treat a link as paid only on the event or a read of the link. |
| What if two people pay the same link? | A single-use link settles on the first confirmed credit and stops minting checkouts. A second transfer lands unmatched and needs manual handling — this is one reason to keep links customer-specific. |
| Is confirmation automatic? | Yes, when the amount matches. No action is needed from you or the customer. |
| How long does matching take? | Usually seconds after the bank reports the credit, but it is asynchronous and not guaranteed — the bank controls when the notification arrives. Build for "later", not "immediately". |
| Which event tells me it worked? | payment.confirmed. On webhook version 2026-08-06 you also get payment_link.paid. There is no sinpe.payment.succeeded. |
The customer can arrive back at your success_url before the money moves, or
without paying at all — they simply closed the banking app. Settle orders on the
signed webhook or on
GET /v1/payments/payment-links/{link_code}, never on the redirect.
16. Link Lifecycle & Status
SINPE Móvil is not a card. The customer leaves the payment page, moves money in their own
banking app, and the confirmation reaches us afterwards — so the customer can arrive back at
your success_url before, or without ever, paying. Treat the signed webhook or
GET /v1/payments/payment-links/{link_code} as the only source of truth.
Status values
| Status | Meaning | Can it still be paid? |
|---|---|---|
unpaid | Created and waiting. This is also the state while a customer has the page open and a payment is in flight. | Yes |
partially_paid | At least one instalment confirmed, with a balance still outstanding. Only reachable on a link created with allow_partial_payment. | Yes |
paid | A bank credit was matched and confirmed, and nothing is outstanding. paid_at, sinpe_ref and transaction_id are populated. | No |
expired | expires_at has passed without payment. | No |
cancelled | Cancelled by you, through the API or the portal. | No |
There is no separate processing or failed status. A payment in flight is
still unpaid — check GET …/payments to see whether an attempt is open.
A failed or abandoned attempt simply leaves the link unpaid so the customer can try
again; it never marks the link itself as failed.
Rules the lifecycle guarantees
| Question | Answer |
|---|---|
| Can a paid link be paid again? | No. A single-use link (the default) stops creating checkout sessions once paid. |
| Can an expired link be reactivated? | Not directly. While it is still unpaid you can PATCH a later expires_at; once expired, create a new link. |
| What if the customer opens the link twice? | They get the same open payment attempt, not a second one — one QR, one amount. Two browsers opening simultaneously also resolve to a single attempt. |
| What HTTP status does a paid, expired or cancelled link return? | 200 with the current status. Not an error — you asked a valid question about a real link. |
Is link_code guessable? | No. It carries 64 bits of randomness and is the capability that grants access to the payment page, so treat it as a secret you share only with that customer. |
| Are expiries applied instantly? | Yes for reads — a link past expires_at reports expired immediately, whether or not the background sweep has run. |
Paying in instalments
Set allow_partial_payment: true and the customer can settle the link over several
payments. On each visit they choose an amount, we open a checkout for exactly that figure, and the
link stays payable until the balance reaches zero.
| Behaviour | Detail |
|---|---|
| Tracking | amount_paid and amount_remaining are on every read. amount_remaining hits 0 exactly when the status becomes paid. |
| Each payment | Recorded separately — GET …/payments lists them, so every instalment can be reconciled against your bank statement individually. |
paid_at | Set only when the link fully settles, never on the first instalment. It means "this invoice is closed". |
| Minimum | minimum_payment_amount is a floor per instalment. It never blocks the final, smaller payment that closes the balance. |
| Overpayment | Settles the link. amount_paid records what actually arrived; amount_remaining is floored at 0 and never goes negative. |
| Expiry | A partly-paid link expires like any other. What was collected stays collected and is still reported; only the outstanding balance stops being payable. |
| Cancelling | Allowed while partly paid — it closes the outstanding balance. Money already taken is unaffected. |
| Duplicate credits | Each instalment is keyed to its own checkout session, so a replayed confirmation cannot count twice. |
The webhook's amount is what this payment was for, not the invoice total.
On an instalment link, read payment_link.amount_total for the invoice and
GET /v1/payments/payment-links/{link_code} for the balance before treating an
order as settled.
Knowing whether the customer saw it
opened_at is stamped the first time the payment page is opened, and
open_count counts every visit. An unpaid link with opened_at: null was
never seen — chase the delivery, not the payment.
Collecting your own details
custom_fields adds your own questions to the payment page. The customer answers them
before paying, and the answers come back on the link and in the payment webhook — so you can ask
for a table number, a delivery note or an internal reference without building your own form.
"custom_fields": [
{ "key": "table_number", "label": "Table number", "type": "numeric", "required": true },
{ "key": "allergies", "label": "Allergies", "type": "text", "max_length": 120 },
{ "key": "collection", "label": "Collection", "type": "dropdown", "options": ["Pick up", "Deliver"] }
]
| Rule | Detail |
|---|---|
| How many | Up to 5. The payment page is one narrow column on a phone; more than that pushes the payment action off the screen. |
key | Lowercase letters, digits and underscores, unique within the link. This is what you read the answer back by. |
label | Required — it is what the customer reads. ≤60 characters, in whatever language you write it. |
type | text (default), numeric, or dropdown. A dropdown must carry options; the others must not. |
required | Default false. When true the customer cannot continue without answering. |
| Reading answers | On GET …/payments per attempt, and in the payment webhook under data.custom_fields. Each answer carries the label as it was worded when they answered. |
| Instalments | Answers belong to the payment, not the link — each instalment (or each payer on a reusable link) answers for itself. |
Choosing which events you receive
By default a link pushes every payment_link.* event. Use notify.events to
narrow that to the ones you act on:
"notify": { "events": ["payment_link.paid", "payment_link.expired"] }
| You send | You receive |
|---|---|
No notify at all | Every payment_link.* event. This is the default, and what every existing link does. |
"events": ["payment_link.paid"] | Only that event. |
"events": [] | No lifecycle events for this link. |
payment.confirmed cannot be listed in notify.events — sending it
returns a 400. It is your proof that money arrived, it is configured on your
business rather than per link, and quietly letting one link mute it is exactly the mistake
that would be discovered only after taking a payment.
17. Errors
Every payment-link error uses one shape. error is human-readable, error_code is what you branch on, and request_id is what you quote to support — it is also returned as the X-Correlation-Id response header on every response, success included.
{
"error": "customer.country_code is required when customer.phone is set.",
"error_code": "invalid_field",
"field": "customer.country_code",
"request_id": "req_71d11cfe"
}
| HTTP | error_code | Meaning |
|---|---|---|
400 | invalid_json | Body was not valid JSON, or a value did not match its declared type. |
400 | invalid_field | A field failed validation. field names it. |
401 | invalid_api_key | Missing, malformed, inactive or unknown key. |
403 | insufficient_permissions | A valid key that may not do this — typically a pk_ key on a secret-key route. |
404 | payment_link_not_found | Unknown link code. Also returned for a link belonging to another merchant, deliberately. |
409 | reference_conflict | You already have a link with this reference. Retrieve it with GET …?reference=. |
409 | idempotency_conflict | Same Idempotency-Key, different request body. |
409 | not_cancellable | The link is no longer unpaid, so it cannot be cancelled. |
409 | not_editable | The link is no longer unpaid, so it cannot be amended. |
429 | rate_limit_exceeded | Too many requests for this API key. Wait for the interval in the Retry-After header. |
500 | internal_error | Unexpected server error. Safe to retry with the same Idempotency-Key. |
503 | temporarily_unavailable | Temporarily unavailable. Retry with backoff. |
18. Outbound Webhooks
When a payment is matched and confirmed, Olanzo sends a POST to your webhook URL — the link's own webhook_url if set, otherwise the one configured for your business under Create → Merchant → your business → E-commerce → Customer return URLs.
Your webhook URL must use https://. These notifications carry payment details and the signature you verify them with, so we do not send them over an unencrypted connection. A plain http:// address is refused when you set or change it — on a business, a payment link, or an individual checkout session. Addresses that resolve to private, internal or loopback ranges are refused for the same reason.
If you configured an http:// address before this requirement, it keeps working and nothing stops today — but move it to https://, because any later edit to that field will be refused until you do.
{
"event": "payment.confirmed",
"session_id": "cs_9b1deb4d3b7d41699c47c0e60807b5a1",
"amount": 28250,
"currency": "CRC",
"order_reference": "INV-2025-001",
"metadata": { "invoice_id": "inv_8492", "source": "erp" },
"customer": {
"id": "cust_9981",
"cedula": "100000000",
"id_type": "fisica",
"first_name": "María",
"last_name": "López",
"email": "maria.lopez@example.com",
"phone": "80000000",
"phone_country_code": 506
},
"line_items": null,
"payer": {
"cedula": "100000000",
"name": "NOMBRE APELLIDO"
},
"sinpe_transaction_id": "SNP-9920141",
"confirmed_at": "2026-08-05T20:10:00Z",
"payment_link": {
"code": "plk_a1b2c3d4e5f60718",
"reference": "INV-2025-001",
"additional_reference": "PO-88234",
"amount_net": 25000,
"iva_rate": 13,
"iva_amount": 3250,
"amount_total": 28250,
"paid_at": "2026-08-05T20:10:00Z",
"sinpe_ref": "SNP-9920141",
"transaction_id": "9920141"
}
}
Reading the payload
| Field | Note |
|---|---|
amount | The total charged — net plus IVA. For a link payment this equals payment_link.amount_total, not amount_net. |
session_id | Carries the checkout session code (cs_…), not the session UUID that create-session returns as session_id. Kept as-is for compatibility with existing integrations. |
order_reference | The link's reference. Same value, named for the session that carried the payment. |
order_id | Version 2026-08-06 only (data.payment.order_id and data.payment_link.order_id; this body is frozen). The Order ID Olanzo issues for every payment link and checkout: unique across the whole platform, the same on every payment attempt of an order, and the reference a payment made with the Olanzo app carries in its bank transfer description. Two letters say what kind of order it is — PL- a payment link, PR- a payment request sent in bulk, PI- an invoice, PO- an online checkout — then 8 letters and digits, for example PO-AB23CD45. Read it as one opaque value; the prefix is for people. Your own number stays in reference / order_reference. Returned on create as order_id; null for links and sessions created before Order IDs were issued. |
customer.phone_country_code | Same value you sent as customer.country_code; the webhook uses the fuller name. |
payment_link | Present only when the payment came from a payment link; null for an ordinary checkout session. |
payer | Who actually transferred the money, as reported by the bank. May legitimately differ from customer — someone can pay a bill on another person's behalf. |
Payload versions
Two payload shapes exist. You stay on whichever you were created with — we never change it under you, because the signature is computed over the exact body bytes, so a shape change would break your parser and your verification at the same moment.
| Version | What you get |
|---|---|
legacy (default) | The flat body shown above. Only payment.confirmed. |
2026-08-06 | Nested envelope with event_id, event_type, created_at and is_test_mode; payment-link lifecycle events; unambiguous session_id vs session_code; and the full amount breakdown. |
Every delivery carries X-Olanzo-Webhook-Version so you can tell them apart, and
X-Olanzo-Event-Id for deduplication. To switch, set webhook_api_version
on your business configuration (or ask support). Test both shapes with the
test endpoint before you move.
Version 2026-08-06 envelope
{
"event_id": "evt_9a174eb7c3f24b1d8e0a5c72",
"event_type": "payment.confirmed",
"created_at": "2026-08-05T20:10:00Z",
"is_test_mode": true,
"data": {
"payment_link": { "link_code": "plk_a1b2c3d4e5f60718", "reference": "INV-2025-001", "order_id": "PL-AB23CD45" },
"session": {
"session_id": "9b1deb4d-3b7d-4169-9c47-c0e60807b5a1",
"session_code": "cs_9b1deb4d3b7d41699c47c0e60807b5a1"
},
"payment": {
"amount": 28250,
"currency": "CRC",
"order_reference": "INV-2025-001",
"order_id": "PL-AB23CD45",
"sinpe_transaction_id": "SNP-9920141",
"confirmed_at": "2026-08-05T20:10:00Z",
"payer": { "cedula": "100000000", "name": "NOMBRE APELLIDO" }
},
"customer": { "id": "cust_9981", "first_name": "María", "country_code": 506, "phone": "80000000" },
"metadata": { "invoice_id": "inv_8492" },
"amounts": { "amount_net": 25000, "iva_amount": 3250, "amount_total": 28250 }
}
}
Payment-link lifecycle events (version 2026-08-06 only)
These remove the need to poll for anything except an unexpected silence.
| Event | Fires when |
|---|---|
payment_link.created | You create a link. |
payment_link.opened | The customer opens the payment page for the first time. Subsequent visits do not re-fire. |
payment_link.partially_paid | An instalment confirmed with a balance still owed. |
payment_link.paid | The link fully settled. Fires alongside payment.confirmed, not instead of it — one is about the invoice closing, the other about the individual credit. |
payment_link.expired | expires_at passed unpaid. The one state change you could not otherwise observe without polling. |
payment_link.cancelled | You cancelled the link. |
Their data.payment_link carries the full link — status, all four amounts, and the timestamps.
Verifying the signature
Every delivery carries X-Olanzo-Signature: sha256=<hex>, and — regardless of
version — also the replay-resistant
X-Olanzo-Signature-V2: t=<unix>,v1=<hex> plus
X-Olanzo-Timestamp. Prefer V2; the original header is kept so nothing breaks.
| Header | Signed content |
|---|---|
X-Olanzo-Signature | HMAC-SHA256 of the raw body. |
X-Olanzo-Signature-V2 | HMAC-SHA256 of "{timestamp}.{raw body}". Reject anything older than about 300 seconds to defeat replays. |
// Node.js — the V2 (replay-resistant) check.
const raw = req.body; // Buffer, NOT the parsed object
const header = req.header("X-Olanzo-Signature-V2"); // "t=1754...,v1=abc..."
const { t, v1 } = Object.fromEntries(header.split(",").map(p => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.sendStatus(400);
const expected = crypto
.createHmac("sha256", process.env.OLANZO_WEBHOOK_SECRET)
.update(`${t}.${raw}`)
.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1))) return res.sendStatus(400);
res.sendStatus(200);
The rules below apply to both headers.
- Algorithm: HMAC-SHA256, lowercase hexadecimal.
- Key: your webhook signing secret, shown with your API keys in the Olanzo Portal under Create → Merchant → your business → E-commerce → API Keys. Test and live keys have different secrets.
- Signed content: the raw request body bytes, exactly as received. Do not parse and re-serialize the JSON first — key order and whitespace matter, and any reformat will fail verification.
- Comparison: use a constant-time comparison.
// Node.js (Express) — capture the RAW body, not the parsed object.
app.post("/webhooks/olanzo",
express.raw({ type: "application/json" }),
(req, res) => {
const expected = crypto
.createHmac("sha256", process.env.OLANZO_WEBHOOK_SECRET)
.update(req.body) // Buffer of raw bytes
.digest("hex");
const received = (req.header("X-Olanzo-Signature") || "").replace(/^sha256=/, "");
const ok = expected.length === received.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
if (!ok) return res.sendStatus(400);
res.sendStatus(200); // acknowledge FIRST, then process asynchronously
});
Delivery behaviour
| Aspect | Behaviour |
|---|---|
| Acknowledgement | Any 2xx. Anything else counts as a failure. |
| Timeout | 60 seconds. Acknowledge quickly and do your processing afterwards. |
| Retries | Up to 5 attempts. Backoff is 3ⁿ × 10 seconds — roughly 30 s, 90 s, 4.5 min and 13.5 min after the first attempt. |
| Delivery guarantee | At least once. A delivery can arrive twice — make your handler idempotent, keyed on session_id plus sinpe_transaction_id. |
| Ordering | Not guaranteed. Never infer state from arrival order; re-read the link if you need certainty. |
| Redirects | Not followed. Give us the final URL. |
| Destination | Must be a public https URL. Private, loopback and link-local addresses are rejected and not retried. |
| No signing secret | The delivery is held unsent rather than signed with an empty key, so a forged webhook can never be made to verify. |
Testing webhooks on this environment
Send yourself a real, signed sample event — same delivery path, same signature, same retry behaviour as a genuine payment, so it proves your endpoint and your verification actually work:
curl -X POST "http://checkoutapi-dev.jirafix.net/v1/payments/webhooks/test" \ -H "Authorization: Bearer sk_test_YOUR_SECRET_KEY"
{
"delivered": true,
"url": "https://your-site.example/webhooks/olanzo",
"event_type": "payment_link.paid",
"event_id": "evt_9a174eb7c3f24b1d8e0a5c72",
"webhook_api_version": "2026-08-06",
"http_status": 200,
"note": "Your endpoint acknowledged the event.",
"request_id": "req_71d11cfe"
}
The sample link in the event is not saved, so a test send never appears in your links or your
reporting. Available on webhook version 2026-08-06 only.
To exercise the real matcher end to end instead, create a link with a test key (sk_test_…), open it, and use the test controls on the hosted page to mark the payment as paid — that is the same code path a live payment takes.
19. Idempotency & Retries
A create request that times out has still, quite possibly, succeeded. Retrying it blindly is how a
customer ends up with two invoices for one order. Send an Idempotency-Key and the
retry is absorbed.
Idempotency-Key: 98cf4fe7-eef4-48db-9ab5-e6a65d190b83
Supported on POST /v1/payments/payment-links and POST /v1/payments/checkout/sessions.
| Situation | Result |
|---|---|
| New key | 201 Created — the resource is created. |
| Same key, same body | 200 OK with the original object. Nothing new is created. The 200 rather than 201 is how you can tell. |
| Same key, different body | 409 idempotency_conflict. Use a fresh key for a genuinely different request. |
| Key longer than 64 characters | 400 invalid_field. It is never silently truncated. |
| Concurrent retries | Both resolve to the same object. Uniqueness is enforced in the database, not by a pre-check, so a race cannot produce two links. |
The key is echoed back in the Idempotency-Key response header. Keys are retained for
the lifetime of the object they created, so a replay works for as long as the link exists.
Use a random UUID, or a value derived from your own order id — for example
create-link:INV-2025-001. Never put customer data in the key: no
names, emails, phone numbers or cédulas. Keys appear in logs.
reference is not a substitute for an idempotency key
Both prevent duplicates, but they answer different questions and behave differently.
| Question | Answer |
|---|---|
Is reference unique forever? | Yes — for the lifetime of your account, across every status. |
| Can I reuse it after the link expires or is cancelled? | No. The reference stays claimed, because a late bank credit still has to match back to the right link. |
| What if I resend an identical request without an idempotency key? | 409 reference_conflict — not a replay. Only an Idempotency-Key produces a replay. |
| How do I recover the link I already created? | GET /v1/payments/payment-links?reference=INV-2025-001. |
| Is the reference space mine alone? | No — the merchant's own portal shares it. Prefix your references (ERP-INV-2025-001) and see below. |
Uniqueness is per merchant, not per API key. The Olanzo portal creates payment links
too — when the merchant invoices an order from Orders, the invoice's number becomes that
link's reference. Those orders can carry a number the merchant typed themselves,
so a reference you have never sent can already be claimed, and you can receive a
409 reference_conflict for one.
Prefix your references with something only your integration issues — ERP-INV-2025-001
rather than INV-2025-001 — and the two spaces cannot collide. If you do hit a
conflict, GET /v1/payments/payment-links?reference=… tells you what already holds
it; a link the merchant created from the portal is a real obligation and must not be worked
around by cancelling it.
20. Not Supported Yet
Stated plainly so you do not design around something that does not exist.
| Capability | Status |
|---|---|
| Refunds | Not available through this API. A SINPE reversal is a transfer made from your own bank account back to the customer — it happens in your banking app, not here, and no refunded status will ever appear on a link. |
| Cards and other rails | SINPE Móvil only. allowed_payment_methods accepts ["sinpe_mobile"]. |
| Currencies other than CRC | Not available on payment links. |
| Changing an amount after creation | Not permitted — it would break payment matching. Cancel and recreate. |
| WhatsApp and push | Not available on the payment-link send API. Email and SMS only. |
21. Testing Your Integration
There is no reserved test range. No card number, cédula, phone number or amount is
treated specially by this API — there is no equivalent of a 4242… test card,
and none is planned. An identification number is checked for shape only: the right
digit count and prefix for a cédula física, jurídica or DIMEX, and 6–20 alphanumeric
characters for a passport. Nothing verifies that the number was ever issued to anyone, so a
made-up but correctly shaped value passes — use one. Do not test with a real
person's identification; you gain nothing by it.
Read this before handing a sandbox link to anyone. Test mode marks the session and keeps its rows out of the merchant's real revenue figures and CRM. It does not put a barrier in front of the payment rail:
- A test session shows the merchant's real SINPE number and a real QR. There is no sandbox bank.
- Matching an incoming bank credit does not consider test mode. A genuine transfer made against those instructions is real money, it reaches the merchant's account, and it will confirm the test session.
- That money is then reported as a test row, so it is easy to lose track of. Recovering it is a bank transfer back out, by hand.
So: on a test session, use the simulate control on the hosted page. Never follow the transfer instructions it displays.
Your test key is what marks the session. Create the session with an
sk_test_ key — creation always requires a secret key, so a publishable
pk_ key is refused 403 here as everywhere else. What makes a session
eligible for simulation is the key that created it, not the host it runs on: a
test session gets the sandbox pipeline wherever it is served, and a live session can never reach
it. Keeping test and live keys apart in your own configuration is what keeps a test run out of the
merchant's reporting — the values inside the request will not do it for you.
The confirmation gate still applies in full in test mode: an unconfirmed link refuses every payment vector, the test-mode confirmation included (Confirming before paying).
22. Versioning & Breaking Changes
A breaking change carries six months' notice, counted from the day it is announced — not from the day it ships. You will always have two full quarters to move.
Announcements go out two ways, and you do not have to be watching for them:
- In the changelog, which is the durable record.
- By email to the contact registered against every API key. Keep that contact current; it is the only channel that reaches you without you looking.
Additive changes — a new optional field, a new value in an existing enumeration, a new endpoint — are not breaking and ship without that notice. Build your client so an unknown field or an unrecognised status is ignored rather than fatal, and additive changes will never reach you as an outage. Webhook payloads carry their own version; see Webhook Versions.
23. Support
support@olanzo.com — the same address the merchant portal shows. Staffed Monday to Friday, 08:00–17:00 Costa Rica time (UTC−6).
No response-time commitment is published yet. Said plainly so you can plan around it: if your launch depends on a guaranteed turnaround, raise that with your account contact rather than assuming one exists. Outside those hours, mail still arrives — it is read the next working day.
Include these and the first reply is far more likely to be the useful one:
- The
request_idfrom the response, if you have it — it identifies the exact call in our logs. - The environment you were on, and the
session_codeorlink_codeinvolved. - The time, with its offset.
Never send us a secret key, in an email or anywhere else. We never ask for one, and no support question needs it. If a key has been exposed, rotate it in the portal first and tell us afterwards.
24. Security & PCI
No card data is handled anywhere in this API. SINPE Móvil moves money between bank accounts by phone number; there is no card number, expiry or CVV in any request, response, webhook or stored record, because there is no card in the flow at all.
We do not publish a PCI DSS self-assessment questionnaire classification. An SAQ level describes a merchant's own card-processing environment, and yours is not determined by us — confirm your scope with your acquirer or your assessor. What we can tell you, and what is usually the answer they need, is the sentence above: integrating this API does not introduce card data into your environment.
What is worth checking on your side:
- Secret keys stay server-side. A
pk_publishable key is the only one that belongs in a browser, and the API refuses apk_key on every secret-key route with403. - Verify the
X-Olanzo-Signature-V2header on every webhook and reject anything outside the timestamp tolerance — an unverified webhook endpoint is the one part of this integration an outsider can reach directly (§18). - A cédula is personal data, and only some of it is masked for you. The value a
payer types at the confirmation gate comes back masked, as
confirmed_cedula_masked. A cédula you supplied on a link is returned to you in full ascustomer.cedula, and webhook payloads carry customer and payer identification in full — they have to, because that is what you match against. Treat everything you receive as personal data in your own storage, logs and support tooling; do not assume it arrived masked.