Developers
Accept payments with a few API calls
Your server creates a checkout and sends the customer to its address. The customer pays on Teya's secure page and comes back to your site. We then confirm the payment with Teya and send your server a signed notification.
Every request goes to https://exsolutio.xyz/v1 with your secret key in the Authorization header. Keys starting with sk_test_ create test payments. Keys starting with sk_live_ take real money and work once your account is approved. Create keys in your dashboard under Developers.
Amounts are whole numbers in the currency's smallest unit, so 1999 means €19.99.
Create a checkout
| currencyrequired | EUR, GBP, CZK, PLN, HUF, SEK, DKK, NOK, RON, CHF or ISK. |
| items | A list of {description, quantity, unit_price}, each with an optional sku (your product code) and vat_rate (a percentage such as 20 or 5.5). Shown to the customer and sent to Teya. SKUs power the top-products report. |
| amount | Required if you don't send items. If you send both, it must equal the items total. |
| reference | Your order number. Shown on the checkout and the receipt. |
| customer | What you already know, so the customer isn't asked twice: email, name, phone, company, vat_id, country (ISO code such as SK), billing_address and shipping_address (each {name, line1, line2, city, postcode, region, country}). |
| collect | Overrides what the checkout page asks, for this checkout only. See What the checkout asks. |
| success_url | Where the customer goes after paying. We add ?checkout_id=…. |
| cancel_url | Where the customer goes if they leave without paying. |
| metadata | Up to 20 key/value pairs of your own. Returned in notifications. |
curl https://exsolutio.xyz/v1/checkouts \ -H "Authorization: Bearer sk_test_…" \ -H "Idempotency-Key: order-1001" \ -H "Content-Type: application/json" \ -d '{ "currency": "EUR", "reference": "ORDER-1001", "items": [ {"description": "Espresso beans 1 kg", "quantity": 2, "unit_price": 1250}, {"description": "Hand grinder", "quantity": 1, "unit_price": 4900} ], "success_url": "https://yourshop.com/thanks", "cancel_url": "https://yourshop.com/cart" }'
The response includes url. Redirect the customer there.
{
"id": "co_8Hq2…",
"status": "open",
"amount": 7400,
"currency": "EUR",
"url": "https://exsolutio.xyz/c/co_8Hq2…",
"expires_at": "…" // open for 24 hours
}
What the checkout asks
By default the checkout asks for the customer's email and country. The country is pre-selected from the customer's connection, so it rarely costs any typing. Set your own defaults in the dashboard under Checkout settings, or send collect with a checkout or payment link. Each field is "off", "optional" or "required".
{
"collect": {
"email": "required", "name": "optional", "phone": "off",
"country": "required", "company": "off",
"shipping_address": "required", "billing_address": "optional",
"terms_url": "https://yourshop.com/terms",
"privacy_url": "https://yourshop.com/privacy",
"marketing_consent": "Send me news and offers",
"fields": [
{"key": "grind", "label": "Grind", "type": "dropdown",
"options": ["Whole bean", "Espresso"], "required": true}
]
}
}
| terms_url | If set, the customer must tick "I agree to the terms of sale". We record when. |
| marketing_consent | If set, an unticked box with this wording. Each answer is stored with the exact wording and time. |
| fields | Up to 3 questions of your own. The types are text, dropdown (with options), number (optional min/max) and checkbox. Please don't ask for health, religion or other sensitive details. |
Card details are never part of this: customers always enter them on Teya's page.
When the customer returns
After paying, the customer lands on your success_url with ?checkout_id=co_…. Don't treat the redirect itself as proof of payment. Confirm the payment before you ship:
The checkout comes back with everything collected: customer, billing_address, shipping_address, answers, consents, items, payment_attempts, refunds, disputes, payment_method (card, apple_pay or google_pay) and card (brand and last 4 digits, when Teya provides them). Notifications carry the same data.
Apple Pay needs no extra code or setup: Teya's payment page offers it automatically on devices that support it.
Fulfil the order when status is paid. The other statuses are open, failed (the card was declined and the customer can try again), expired, refunded and partially_refunded.
Payment notifications
Add your notification address in the dashboard under Developers. We send a POST for each of these events:
| checkout.completed | The payment succeeded and has been confirmed with Teya. |
| checkout.payment_failed | The card was declined. The checkout stays open for another try. |
| checkout.expired | Nobody paid within 24 hours. |
| refund.succeeded | A refund went through. refund_amount says how much. |
| dispute.opened | The cardholder disputed the payment (a chargeback). dispute_amount says how much. |
| dispute.updated | A dispute was won, lost or accepted. See dispute_status. |
Each notification carries an X-Signature header of the form t=1790000000,v1=…. Check it with your signing secret, which starts with whsec_, before trusting the message. Reply with any 2xx status. We retry after 10 seconds and again after a minute.
Python
import hmac, hashlib, time def verify(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.split("=", 1) for p in header.split(",")) if abs(time.time() - int(parts["t"])) > 300: # reject old messages return False expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, parts["v1"])
Node.js
const crypto = require("crypto"); function verify(rawBody, header, secret) { const p = Object.fromEntries(header.split(",").map(x => x.split("="))); if (Math.abs(Date.now() / 1000 - Number(p.t)) > 300) return false; const expected = crypto.createHmac("sha256", secret).update(p.t + ".").update(rawBody).digest("hex"); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(p.v1)); }
PHP
function verify(string $raw, string $header, string $secret): bool { parse_str(str_replace(",", "&", $header), $p); if (abs(time() - (int)$p["t"]) > 300) return false; $expected = hash_hmac("sha256", $p["t"] . "." . $raw, $secret); return hash_equals($expected, $p["v1"]); }
Refunds
Send {"checkout": "co_…"} to refund in full, or add "amount" for a partial refund. You can make several partial refunds up to the amount paid. You can also refund from the dashboard.
Customers
Customers are recognised by email, separately for each shop: a customer of yours is never linked to any other shop.
Lists your customers, most recent first. Use limit (up to 100) and offset to page through them, or ?email= to look one up.
One customer with their orders, addresses and consent history.
For a "right to be forgotten" request. It permanently removes the customer's name, email, phone, addresses, answers and consents. Amounts stay in your records for accounting.
Payment links
Creates a link that customers can pay any number of times. Send title, amount, currency, and allow_quantity if customers may buy more than one. For donations or tips, send "amount_mode": "custom" with min_amount and max_amount, and the customer chooses the amount. max_uses stops the link after that many payments, expires_at (a date such as 2026-12-31) ends it on that day, and collect works as for checkouts. Add ?utm_source=… or ?src=… to a link to see in your dashboard where payments came from. The response includes the link's url. GET /v1/payment_links lists your links.
Testing
With a test key, payments go through our payment simulator, where you choose whether the customer pays with Apple Pay or a card, or whether the card is declined or the payment cancelled. Everything else behaves exactly as it will when you're live, including notifications and refunds.
To try Teya's real payment page, connect your Teya Staging credentials in the dashboard. Test payments then use Teya's test environment and test cards. No real money moves in either case.
Errors and retries
Errors come back with a 4xx or 5xx status and a body such as {"error": {"type": "invalid_request", "message": "…", "param": "amount"}}. The message is written to be shown to a developer.
Send an Idempotency-Key header on POST requests, for example your order number. If a request times out and you send it again with the same key, you get the original result back instead of a duplicate checkout or refund.