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.

Keep secret keys on your server. Never put them in a web page or a mobile app.

Amounts are whole numbers in the currency's smallest unit, so 1999 means €19.99.

Create a checkout

POST/v1/checkouts
currencyrequiredEUR, GBP, CZK, PLN, HUF, SEK, DKK, NOK, RON, CHF or ISK.
itemsA 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.
amountRequired if you don't send items. If you send both, it must equal the items total.
referenceYour order number. Shown on the checkout and the receipt.
customerWhat 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}).
collectOverrides what the checkout page asks, for this checkout only. See What the checkout asks.
success_urlWhere the customer goes after paying. We add ?checkout_id=….
cancel_urlWhere the customer goes if they leave without paying.
metadataUp 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_urlIf set, the customer must tick "I agree to the terms of sale". We record when.
marketing_consentIf set, an unticked box with this wording. Each answer is stored with the exact wording and time.
fieldsUp 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:

GET/v1/checkouts/{id}

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.completedThe payment succeeded and has been confirmed with Teya.
checkout.payment_failedThe card was declined. The checkout stays open for another try.
checkout.expiredNobody paid within 24 hours.
refund.succeededA refund went through. refund_amount says how much.
dispute.openedThe cardholder disputed the payment (a chargeback). dispute_amount says how much.
dispute.updatedA 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

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

GET/v1/customers

Lists your customers, most recent first. Use limit (up to 100) and offset to page through them, or ?email= to look one up.

GET/v1/customers/{id}

One customer with their orders, addresses and consent history.

DELETE/v1/customers/{id}

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.

Pay button

To add a button to any web page, paste this where the button should appear. Replace the link with one of your payment links.

<script src="https://exsolutio.xyz/js/button.js"
        data-link="https://exsolutio.xyz/p/your-link"
        data-label="Buy now"
        data-color="#0e7c6b"></script>

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.