AMIO for developers
Connect your POS, online shop or bank system to AMIO. The API gives the same results as working in the app, with the same rules, roles and audit trail.
Authentication
An admin of your organisation creates an API key in the app, under Developers. Send it as Authorization: Bearer amio_live_... with every request to /api/public/v1. The key is shown only once and can be revoked at any time.
The API is for servers, not browsers: CORS is off, and a key must never be put in code that runs on a customer's device.
curl https://www.getamio.com/api/public/v1/quotes \
-H "Authorization: Bearer amio_live_..." \
-H "Idempotency-Key: 6f1c2f9e-6d0b-4f7e-9a51-2c8f2f4b7a10" \
-H "Content-Type: application/json" \
-d '{"amount":{"amountMinor":"12990000","currency":"RSD"},
"description":"Laptop","instalments":[6,12]}'Roles and permissions
Every key has one role of your organisation, chosen when it is created, and may do only what that role may do in the app. A key can also be read-only. Merchants: admin, manager and staff; partners: admin, approver and analyst.
A key acts on behalf of the person who created it and stops working when that person leaves the team. The limit is 120 requests per minute per key; above that you get 429 with a Retry-After header.
Idempotency
Every POST and PUT needs an Idempotency-Key header, for example a UUID. A repeated request with the same key and the same body returns the same response (with an Idempotent-Replayed header), so a quote or a return is never created twice. The same key with a different body returns 409 IDEMPOTENCY_CONFLICT. Keys are remembered for 24 hours.
Errors and amounts
Errors always have the same shape: error.code, error.message and error.correlationId, and the same id is in the X-Correlation-Id header. Quote it when you contact support. Changes use versions: send the version you last received; if something changed in the meantime you get 409 with the reason VERSION_CONFLICT.
Amounts are always an object { amountMinor, currency }, where amountMinor is an integer in the currency's smallest unit, written as text, for example "12990000" for RSD 129,900.00.
{
"error": {
"code": "CONFLICT",
"message": "VERSION_CONFLICT",
"reason": "VERSION_CONFLICT",
"correlationId": "c0a8..."
}
}Webhooks
An admin adds an endpoint and chooses events. AMIO sends a POST with the JSON body { id, type, createdAt, livemode, data } when a status changes. Answer with 2xx within 10 seconds; failed deliveries are retried with growing delays, and the latest attempts are visible in the app.
Every message has the header AMIO-Signature: t=<unix time>,v1=<hex HMAC-SHA256 of "t.body">, signed with the endpoint's secret. Verify it over the raw body and reject old messages. The same event can arrive more than once; use its id to process it once.
- Merchants: quote.created, quote.status_changed, application.status_changed, refund.status_changed
- Partners: application.status_changed, deferral.requested, deferral.decided
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string), before JSON.parse.
export function verifyAmioWebhook(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(
String(header ?? "").split(",").map((part) => part.trim().split("=", 2)),
);
const timestamp = Number(parts.t);
if (!parts.v1 || !Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}
// Express: app.post("/amio/webhooks", express.text({ type: "*/*" }), (req, res) => {
// if (!verifyAmioWebhook(req.body, req.get("AMIO-Signature"), process.env.AMIO_WEBHOOK_SECRET))
// return res.sendStatus(400);
// const event = JSON.parse(req.body); // { id, type, createdAt, livemode, data }
// res.sendStatus(200);
// });Embedded screens
You can show the new quote and quote status screens inside your portal in an iframe: /{locale}/embed/merchant/quotes/new and /{locale}/embed/merchant/quotes/{id}. An admin first adds the portal origin; on every other origin the browser blocks them (Content-Security-Policy: frame-ancestors).
Browsers do not send AMIO's sign-in cookie to an iframe on another site, so your server asks for a one-time token: POST /api/public/v1/embed-tokens. The token is valid for 5 minutes, and on first open AMIO exchanges it for a short session for your company only, with the key's role. If your portal is on the same site as AMIO, a normal sign-in works too (open it in a popup).
// On your server, with a merchant key:
const response = await fetch("https://www.getamio.com/api/public/v1/embed-tokens", {
method: "POST",
headers: {
Authorization: "Bearer amio_live_...",
"Idempotency-Key": crypto.randomUUID(),
"Content-Type": "application/json",
},
body: JSON.stringify({ locale: "sr-Latn-RS" }), // or { quoteId } for one quote's status
});
const { url } = await response.json();
// In your portal page (an origin registered under Developers):
// <iframe src="{url}" width="480" height="820" title="AMIO"></iframe>Test environment
Keys that start with amio_test_ exist only in the test environment and work there with the test partner. Production uses only amio_live_ keys. Quotes of a merchant that is not active yet are always marked as test (mode SANDBOX, livemode false in webhook messages).