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.

⚡ Quick Start

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.

PropertyValue
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
🔐 Your secret key is server-side only

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

PrefixWhere it belongsWhat it can do
sk_test_…Your server onlyCreate, read, update and cancel payment links and checkout sessions.
pk_…Safe in a browserRead-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.

IdentifierFormatExample
link_codeplk_ + 16 hex charactersplk_a1b2c3d4e5f60718
link_idUUID7c1f5e02-9d3a-4b77-9a41-2f0c8e6b1d55
session_codecs_ + 32 hex characterscs_9b1deb4d3b7d41699c47c0e60807b5a1
session_idUUID9b1deb4d-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 CriterionFields EvaluatedBehavior
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

POST /v1/payments/checkout/sessions

Creates a pending e-commerce checkout session and returns checkout_url (https://checkout-dev.jirafix.net/checkout/{session_code}).

Request

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
Idempotency-KeyheaderstringNoAt 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-TypeheaderstringYesapplication/json.
amountbodydecimalYesWhat 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.
currencybodystringNoCRC or USD. Omitted falls back to the merchant’s default, then CRC. Any other value is refused rather than silently overridden.
order_referencebodystringNoYour 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.
descriptionbodystringNoShown on the payment page. At most 2000 characters.
success_urlbodystringNoWhere 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_urlbodystringNoBack-link shown on the failed result page. Same URL rules as success_url.
cancel_urlbodystringNoBack-link shown on the cancelled result page. Same URL rules.
webhook_urlbodystringNoPer-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_urlbodystringNoThe 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.
metadatabodyobjectNoYour own key/values, returned on every read and delivered with the payment webhook. At most 256 KB serialized.
customerbodyobjectNoContact details used to pre-fill the hosted page.
customer.idbodystringNoYour own customer identifier. At most 128 characters.
customer.cedulabodystringNoIdentification 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_typebodystringNofisica (default), juridica, dimex or passport.
customer.first_namebodystringNoAt most 512 characters.
customer.last_namebodystringNoAt most 512 characters.
customer.emailbodystringNoAt most 320 characters, and validated.
customer.phonebodystringNoDigits only, 8–15, no country prefix and no +, spaces or dashes.
customer.country_codebodyintegerNoDialling prefix, 1–999. Required once customer.phone is set.
customer_phonebodystringNoRoot-level alias of customer.phone. The nested value wins when both are sent.
customer_phone_country_codebodyintegerNoRoot-level alias of customer.country_code. The nested value wins.
line_itemsbodyarrayNoCart 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[].namebodystringNoAt most 512 characters.
line_items[].skubodystringNoAt most 128 characters.
line_items[].quantitybodyintegerNoHow many of this line.
line_items[].unit_pricebodydecimalNoPrice per unit, as you priced it.
line_items[].image_urlbodystringNoAbsolute https URL. At most 2048 characters.
line_items[].category.idbodystringNoYour taxonomy id. At most 64 characters.
line_items[].category.namebodystringNoCategory label. At most 256 characters.
line_items[].line_typebodystringNoWhat 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_ratebodydecimalNoThe IVA percentage this line was priced at (13.00 = 13%). Carried, not applied.
creation_sourcebodystringNoWhich 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

Statuserror_codeMeaning
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.
400invalid_fieldA field failed validation; field names it — amount, order_reference, webhook_url, line_items or Idempotency-Key.
400invalid_jsonBody was not valid JSON, or a value did not match its declared type.
409idempotency_conflictSame Idempotency-Key, different request body.
422checkout_not_readyThe 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.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.

4. Get Session Details

GET /v1/payments/checkout/sessions/{session_code}

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

NameInTypeRequiredDescription
session_codepathstringYesThe 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

Statuserror_codeMeaning
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).
404session_not_foundUnknown session code. Also returned for a session belonging to another merchant, deliberately — the two are indistinguishable from outside.

5. Poll Session Status

GET /v1/payments/checkout/sessions/{session_code}/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

NameInTypeRequiredDescription
session_codepathstringYesThe 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

Statuserror_codeMeaning
200—The session’s current status only — the lightweight shape meant for polling.
404session_not_foundUnknown session code, or one belonging to another merchant.

6. Cancel Session

POST /v1/payments/checkout/sessions/{session_code}/cancel

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

NameInTypeRequiredDescription
session_codepathstringYesThe 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—NoThis 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

Statuserror_codeMeaning
200—Cancelled. Returns { "status": "cancelled" }.
400session_not_cancellableReturned 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.
429rate_limit_exceededToo many requests. Wait for the interval in the Retry-After header.
POST /v1/payments/payment-links

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

FieldTypeRequiredDescription
referencestring✅ YesInvoice or order number. Unique per merchant, forever — see Idempotency. ≤30 chars.
additional_referencestringNoSecondary reference (PO / internal id). ≤40 chars. Not shown to the customer.
amount_netnumberCond.Net colones before IVA. See Amounts & IVA. Exactly one of amount_net, amount_total or line_items.
amount_totalnumberCond.Gross colones including IVA, if your system stores totals. Net is derived from it.
currencystringNoMust be CRC. Any other value is rejected with 400 invalid_field.
iva_ratenumberNoIVA % (0–99.99, ≤2 decimals). null = no tax line. 0 = explicitly exempt. 13 = CR standard.
line_itemsarrayNoCart lines, each with its own optional iva_rate — the way to mix tax rates in one payment. See Amounts & IVA.
line_items[].line_typestringNoWhat 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.
descriptionstringNoShown on the payment page. ≤140 chars.
statement_descriptionstringNoShort customer-visible payment concept. ≤140 chars.
expires_atISO 8601NoUTC expiry, at least 5 minutes ahead. Omit for a link that never expires.
reusablebooleanNoDefault false — single-use. true keeps the link payable after the first payment.
max_paymentsintegerNoCap for a reusable link. Requires reusable: true when above 1.
allow_partial_paymentbooleanNoDefault false. true lets the customer pay in instalments — see Paying in instalments.
minimum_payment_amountnumberNoSmallest single instalment. Requires allow_partial_payment: true and must not exceed the total.
allowed_payment_methodsarrayNoOnly ["sinpe_mobile"] is supported. Anything else is rejected rather than ignored.
localestringNoBCP-47 tag, e.g. es-CR or en-US. Sets the hosted page language; defaults to your business setting.
success_urlstringNoWhere the customer lands after paying. return_url is an accepted alias. Must be absolute http/https.
failure_url / cancel_urlstringNoSame rules. Default to your business settings, then the hosted result page.
webhook_urlstringNoOverrides your business webhook URL for payments against this link only. Must be https:// and publicly reachable.
metadataobjectNoYour own key/value data. Returned on every read and delivered with the payment webhook. ≤256 KB serialized.
custom_fieldsarrayNoYour own questions, shown on the payment page — see Collecting your own details. Up to 5.
require_order_confirmationbooleanNoDefault 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_cedulabooleanNoDefault 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.eventsarrayNoWhich 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.idstringNoYour customer identifier — Olanzo exposes no internal customer id here. customer.external_id is an alias. ≤128 chars.
customer.typestringNoindividual (default) or business.
customer.first_namestringNoSend 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_namestringCond.Required when customer.type is business. ≤300 chars.
customer.last_namestringNoCustomer last name.
customer.emailstringCond.Used for customer auto-matching. Required when send.channels includes email.
customer.phonestringCond.Digits-only national number, no prefix. Requires country_code. Required when send.channels includes sms.
customer.country_codeintegerCond.Dialling prefix (e.g. 506). Required when phone is set, and when send.channels includes sms.
customer.cedulastringNoNational ID. Pre-fills and locks the identification step on the payment page.
customer.id_typestringNofisica, juridica, dimex or passport. Inferred from the document when omitted; rejected only if it contradicts what you sent.
customer.billing_addressobjectNoline1, line2, city, province, postal_code, country. Stored and sent with the webhook; never shown to the customer.
send.channelsarrayNoemail 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.remindersarrayNoAt 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

HeaderRequiredDescription
Authorization✅ YesBearer sk_test_…. A pk_ key returns 403 insufficient_permissions.
Content-Type✅ Yesapplication/json
Idempotency-KeyNo≤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

Statuserror_codeMeaning
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.
400invalid_fieldA field failed validation; field names it.
400invalid_jsonBody was not valid JSON, or a value did not match its declared type.
404payment_link_not_foundThe key resolved to no active merchant.
409reference_conflictYou already have a link with this reference. Retrieve it with GET …?reference= rather than retrying.
409idempotency_conflictSame Idempotency-Key, different request body.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.

8. Amounts & IVA

Money is the easiest thing to get wrong, so these rules are exact.

RuleDetail
UnitWhole colones, not céntimos. ₡25 000 is 25000. There is no ×100 minor-unit convention.
TypeA JSON number. Decimals are accepted but limited to 2 places; a third is rejected with 400 invalid_field rather than silently rounded.
MinimumGreater than 0.
Maximum99999999.99 per link. Your customer's own bank sets the per-transfer SINPE limit, which is usually far lower — Olanzo does not control it.
CurrencyCRC only.
Tax formulaiva_amount = round(amount_net × iva_rate ÷ 100) to whole colones, half-up away from zero. amount_total = amount_net + iva_amount.
iva_rate omittedNo 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 insteadSend 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 ratesUse 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.
DiscountsA 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 largeThe 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
GET /v1/payments/payment-links/{link_code}

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

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
link_codepathstringYesThe link’s code, 8–24 characters. Anything outside that range is treated as unknown.

Responses

Statuserror_codeMeaning
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.
404payment_link_not_foundUnknown link code. Also returned for a link belonging to another merchant, deliberately.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.
GET /v1/payments/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

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
referencequerystringNoExact match on your invoice/order reference.
statusquerystringNounpaid, partially_paid, paid, expired, cancelled or all. Anything else is 400 invalid_field.
created_afterquerydate-timeNoISO 8601 UTC lower bound on creation time.
created_beforequerydate-timeNoISO 8601 UTC upper bound on creation time.
pagequeryintegerNoDefaults to 1. A value below 1 is treated as 1 rather than refused.
page_sizequeryintegerNoDefaults 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

Statuserror_codeMeaning
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.
400invalid_fieldstatus was not one of unpaid, partially_paid, paid, expired, cancelled or all.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.
PATCH /v1/payments/payment-links/{link_code}

Amends an unpaid link. Anything else returns 409 not_editable.

Amount and reference cannot be changed

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

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
link_codepathstringYesThe link’s code, 8–24 characters. Anything outside that range is treated as unknown.
Content-TypeheaderstringYesapplication/json.
descriptionbodystringNoReplaces the description shown on the payment page.
statement_descriptionbodystringNoShort customer-visible payment concept, separate from description.
additional_referencebodystringNoSecondary reference, such as a PO number.
expires_atbodydate-timeNoNew absolute UTC expiry, and it must still be at least 5 minutes out. Ignored when clear_expires_at is true.
clear_expires_atbodybooleanNotrue removes the expiry entirely — the link stays payable until paid or cancelled.
localebodystringNoCheckout language as a BCP-47 tag, e.g. es-CR.
return_urlbodystringNoWhere the customer lands after paying. Alias of success_url; return_url wins when both are sent.
success_urlbodystringNoSee return_url.
failure_urlbodystringNoBack-link shown on the failed result page.
cancel_urlbodystringNoBack-link shown on the cancelled result page.
webhook_urlbodystringNoWebhook destination for payments against this link.
metadatabodyobjectNoReplaces 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_cedulabodybooleanNoWhether 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_confirmationbodybooleanNoConfirm-before-paying gate. Same null-means-unchanged rule.

Responses

Statuserror_codeMeaning
200—The amended link.
400invalid_fieldA field failed validation; field names it.
404payment_link_not_foundUnknown link code, or one belonging to another merchant.
409not_editableThe link is no longer unpaid, so it cannot be amended.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.
POST /v1/payments/payment-links/{link_code}/cancel

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

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
link_codepathstringYesThe link’s code, 8–24 characters. Anything outside that range is treated as unknown.
—body—NoThis operation takes no body.

Responses

Statuserror_codeMeaning
200—The cancelled link. It stops accepting payment immediately.
404payment_link_not_foundUnknown link code, or one belonging to another merchant.
409not_cancellableThe link is no longer unpaid, so it cannot be cancelled.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.
GET /v1/payments/payment-links/{link_code}/payments

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

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
link_codepathstringYesThe 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

Statuserror_codeMeaning
200—Every payment attempt recorded against the link, settled or not. A link paid in instalments has one entry per instalment.
404payment_link_not_foundUnknown link code, or one belonging to another merchant.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.
POST /v1/payments/payment-links/{linkCode}/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

NameInTypeRequiredDescription
linkCodepathstringYesThe 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.
amountbodydecimalNoHow 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

Statuserror_codeMeaning
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.
400invalid_fieldThe amount was not acceptable for this link; field names it.
404payment_link_not_foundUnknown link code.
429rate_limit_exceededToo many requests. Wait for the interval in the Retry-After header.

Confirming before paying

Not yet available on the hosted payment page

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:

FlagWhat it actually does
require_order_confirmationVerifies. 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_cedulaCollects. 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.

POST /v1/payments/payment-links/{linkCode}/confirm

Public capability endpoint — the link code is the token, no API key required.

Request Fields

FieldTypeRequiredDescription
order_numberstringCond.Required when the link was created with require_order_confirmation. Compared server-side against the link's own reference, Order ID or additional reference.
cedulastringCond.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_typestringNoIdentification 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

Statuserror_codeMeaning
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.
400invalid_fieldThe identification number was not a valid cédula física, jurídica, DIMEX or passport. field is cedula.
400invalid_fieldThe 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.
403confirmation_lockedTen 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.
404payment_link_not_foundUnknown link code.
429rate_limit_exceededToo many requests. Wait for the interval in the Retry-After header.
OutcomeWhat you get
Correct200 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.
Wrong400 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 identification400 invalid_field pointing at cedula — format is checked before anything is compared.
Too many attempts403 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 cancelled200 with that state, so the page shows the right view instead of a confirmation form.
The ten-attempt cap, in full

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.
The gate protects the money path, not just the page

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.

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 sendWhat happens
No send objectNothing 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_at400 on that reminder field. The time cannot be computed, so it is not accepted.
A test-mode secret keyThe ledger records skipped with skip_reason: test_mode. No message leaves the account.
Paid, cancelled or expired at send timeThe 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.

POST /v1/payments/payment-links/{linkCode}/send

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

NameInTypeRequiredDescription
AuthorizationheaderstringYesBearer sk_… — your secret key. A publishable pk_ key is refused with 403.
linkCodepathstringYesThe link’s code, 8–24 characters.
Idempotency-KeyheaderstringNoIdentifies 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-TypeheaderstringYesapplication/json.
channelsbodyarrayYesemail and/or sms. At least one is required.
remindersbodyarrayNoAt most 3, at least 24 hours apart, and never after the link expires.
reminders[].days_before_expirybodyintegerNoSend 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_sendbodyintegerNoSend 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

Statuserror_codeMeaning
200—The link, with the queued sends on it. Queued, not delivered — delivery outcome arrives separately.
400invalid_fieldNo channel was given, or a reminder broke one of the rules above; field names it.
404payment_link_not_foundUnknown link code, or one belonging to another merchant.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
429rate_limit_exceededToo many requests for this key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily unavailable. Retry with backoff.
Reminders are capped on purpose

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.

QuestionAnswer
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.
Never treat the return redirect as payment

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

A redirect is not proof of payment

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

StatusMeaningCan it still be paid?
unpaidCreated and waiting. This is also the state while a customer has the page open and a payment is in flight.Yes
partially_paidAt least one instalment confirmed, with a balance still outstanding. Only reachable on a link created with allow_partial_payment.Yes
paidA bank credit was matched and confirmed, and nothing is outstanding. paid_at, sinpe_ref and transaction_id are populated.No
expiredexpires_at has passed without payment.No
cancelledCancelled 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

QuestionAnswer
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.

BehaviourDetail
Trackingamount_paid and amount_remaining are on every read. amount_remaining hits 0 exactly when the status becomes paid.
Each paymentRecorded separately — GET …/payments lists them, so every instalment can be reconciled against your bank statement individually.
paid_atSet only when the link fully settles, never on the first instalment. It means "this invoice is closed".
Minimumminimum_payment_amount is a floor per instalment. It never blocks the final, smaller payment that closes the balance.
OverpaymentSettles the link. amount_paid records what actually arrived; amount_remaining is floored at 0 and never goes negative.
ExpiryA partly-paid link expires like any other. What was collected stays collected and is still reported; only the outstanding balance stops being payable.
CancellingAllowed while partly paid — it closes the outstanding balance. Money already taken is unaffected.
Duplicate creditsEach instalment is keyed to its own checkout session, so a replayed confirmation cannot count twice.
Instalments change what the amount means

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"] }
]
RuleDetail
How manyUp to 5. The payment page is one narrow column on a phone; more than that pushes the payment action off the screen.
keyLowercase letters, digits and underscores, unique within the link. This is what you read the answer back by.
labelRequired — it is what the customer reads. ≤60 characters, in whatever language you write it.
typetext (default), numeric, or dropdown. A dropdown must carry options; the others must not.
requiredDefault false. When true the customer cannot continue without answering.
Reading answersOn 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.
InstalmentsAnswers 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 sendYou receive
No notify at allEvery 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.
This never switches off your payment webhook

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"
}
HTTPerror_codeMeaning
400invalid_jsonBody was not valid JSON, or a value did not match its declared type.
400invalid_fieldA field failed validation. field names it.
401invalid_api_keyMissing, malformed, inactive or unknown key.
403insufficient_permissionsA valid key that may not do this — typically a pk_ key on a secret-key route.
404payment_link_not_foundUnknown link code. Also returned for a link belonging to another merchant, deliberately.
409reference_conflictYou already have a link with this reference. Retrieve it with GET …?reference=.
409idempotency_conflictSame Idempotency-Key, different request body.
409not_cancellableThe link is no longer unpaid, so it cannot be cancelled.
409not_editableThe link is no longer unpaid, so it cannot be amended.
429rate_limit_exceededToo many requests for this API key. Wait for the interval in the Retry-After header.
500internal_errorUnexpected server error. Safe to retry with the same Idempotency-Key.
503temporarily_unavailableTemporarily 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

FieldNote
amountThe total charged — net plus IVA. For a link payment this equals payment_link.amount_total, not amount_net.
session_idCarries 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_referenceThe link's reference. Same value, named for the session that carried the payment.
order_idVersion 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_codeSame value you sent as customer.country_code; the webhook uses the fuller name.
payment_linkPresent only when the payment came from a payment link; null for an ordinary checkout session.
payerWho 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.

VersionWhat you get
legacy (default)The flat body shown above. Only payment.confirmed.
2026-08-06Nested 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.

EventFires when
payment_link.createdYou create a link.
payment_link.openedThe customer opens the payment page for the first time. Subsequent visits do not re-fire.
payment_link.partially_paidAn instalment confirmed with a balance still owed.
payment_link.paidThe 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.expiredexpires_at passed unpaid. The one state change you could not otherwise observe without polling.
payment_link.cancelledYou 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.

HeaderSigned content
X-Olanzo-SignatureHMAC-SHA256 of the raw body.
X-Olanzo-Signature-V2HMAC-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.

// 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

AspectBehaviour
AcknowledgementAny 2xx. Anything else counts as a failure.
Timeout60 seconds. Acknowledge quickly and do your processing afterwards.
RetriesUp 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 guaranteeAt least once. A delivery can arrive twice — make your handler idempotent, keyed on session_id plus sinpe_transaction_id.
OrderingNot guaranteed. Never infer state from arrival order; re-read the link if you need certainty.
RedirectsNot followed. Give us the final URL.
DestinationMust be a public https URL. Private, loopback and link-local addresses are rejected and not retried.
No signing secretThe 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.

SituationResult
New key201 Created — the resource is created.
Same key, same body200 OK with the original object. Nothing new is created. The 200 rather than 201 is how you can tell.
Same key, different body409 idempotency_conflict. Use a fresh key for a genuinely different request.
Key longer than 64 characters400 invalid_field. It is never silently truncated.
Concurrent retriesBoth 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.

Choose keys carefully

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.

QuestionAnswer
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.
The reference space is shared with the merchant's own portal

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.

CapabilityStatus
RefundsNot 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 railsSINPE Móvil only. allowed_payment_methods accepts ["sinpe_mobile"].
Currencies other than CRCNot available on payment links.
Changing an amount after creationNot permitted — it would break payment matching. Cancel and recreate.
WhatsApp and pushNot 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.

Test mode separates the reporting, not the money

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:

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:

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: