Start in 5 minutes
- Sign up on the Lapisan Console with your work email. A link signs you in.
- Copy your test key. A test key (
ax_test_…) and a sandbox business are ready at once. Keep the key on your server, never in a web page or an app. - Make your first call. List the sandbox business's documents, then make an invoice. With a test key every write is a dry run: it answers as live would, with
"test": true, and keeps nothing.
export LAPIS_KEY=ax_test_...
curl "https://api.antarax.ai/lapis/v1/documents?limit=5" -H "Authorization: Bearer $LAPIS_KEY"
curl https://api.antarax.ai/lapis/v1/documents \
-H "Authorization: Bearer $LAPIS_KEY" \
-H "Content-Type: application/json" \
-d '{ "type": "invoice", "customer": { "name": "Kedai Runcit Ali" }, "lines": [ { "description": "Roll-up banner", "quantity": 3, "unit_price": "150.00" } ] }'- See it in the Console. Your calls show in the Lapisan Console, under your sandbox business. When you are ready, ask for live access there; AntaraX approves it and you make live keys (
ax_live_…).
Already on Lapis? Keep making keys in Lapis, under Settings → Developers, or link your Lapis account in the Console with the same email.
Building for many businesses at once? Read the Lapisan Connect guide: each business approves your app once, and you call for them with your own key.
Getting a key
Sign up on the Lapisan Console for a test key and a sandbox business at once; live keys come once AntaraX approves your request there. A Lapis account's owner can also make keys in Lapis, under Settings → Developers, and link that account in the Console with the same email. A key is shown once, when it is made; keep it on your server and never in a browser or an app.
Each key has the permissions it was given and nothing more:
lapis:documents:readto read documents and their PDFs, and to verify documentslapis:documents:writeto make, send and cancel documentslapis:payments:writeto record paymentslapis:customers:readto read customers and their statementslapis:webhooks:writeto make and change webhook endpoints, and to read eventslapis:connections:readandlapis:connections:write, for Lapisan Connect: to see and end the connections other accounts gave your apps, and to swap an approval's code
Authentication
Call https://api.antarax.ai/lapis/v1, then the path. Send the key as a bearer token on every call. Bodies are JSON.
curl https://api.antarax.ai/lapis/v1/documents -H "Authorization: Bearer ax_live_..."Every answer carries X-Request-Id (quote it when you ask us about a call) and AntaraX-Version, the date of the shape you are getting.
The key goes in the header and nowhere else. A key in the address (?key=, ?api_key=, ?token= and the like) is refused with 400 key_in_url without being looked up, because addresses end up in logs: stop that key and make another. Bodies over 1 MB are refused with 413 payload_too_large.
Test and live
Keys start ax_test_ or ax_live_. A test key reads the account's real documents and customers, and every write is a dry run: it is checked and worked out exactly as a live call would be, and answered in the same shape with "test": true. Nothing is kept and nobody is emailed. A dry run counts what live would have used (a document issued, an email, a payment) as test usage, so you can see what live would cost; test usage is never capped or billed. A dry run has no url or pdf, since nothing was made, and its events go to the account's test endpoints.
A live key does the real thing: documents are numbered and kept, emails go, money is recorded.
Money, dates and ids
- Money is a string with two decimals beside an ISO currency:
"total": "486.00", "currency": "MYR". Send it the same way. Never a number, so nothing is lost to rounding. - Days are
YYYY-MM-DD. Moments are ISO 8601 in UTC. - Ids carry their kind:
ldoc_for documents,lpay_for payments,lcus_for customers. - Lists are
{ "data": [...], "next": "..." }. Passnextback ascursoruntil it is null. - Fields this API does not know are refused, so a misspelt one never passes quietly.
Errors
Every error has the same shape, and its message is a sentence a person can act on.
{ "error": { "code": "number_taken", "message": "INV-2026-0088 is already on another invoice. The next free number is INV-2026-0089.", "request_id": "req_..." } }| Status | Codes | Meaning |
|---|---|---|
| 400 | key_in_url, invalid_json, invalid_cursor, invalid_account, invalid_code | A key in the address (refused unread: stop that key and make another), a body that is not JSON, a cursor this API did not give out, an AntaraX-Account that is not an account id, or a Connect code that is unknown, used, late, or not yours. |
| 401 | missing_key, invalid_key, revoked_key | No key, a key that is not known, or one that was stopped. The answer carries WWW-Authenticate: Bearer realm="antarax". |
| 403 | missing_scope, wrong_product, api_not_enabled, account_stopped, account_suspended, connection_missing, connection_scope | The key may not do this, the API is not on for the account, or the account is suspended (it can still be read). With AntaraX-Account: no working connection to that account in the key's mode, or the account did not grant this. |
| 404 | not_found | No such document or customer on this account. |
| 409 | number_taken, paid, cancelled, already_cancelled, not_issued, approval_required, conflict, idempotency_in_progress, endpoint_limit | The document's state does not allow it, the first call with this Idempotency-Key is still running, or the account has its 10 webhook endpoints. The message says what to do. |
| 413 | payload_too_large | The body is over 1 MB (15 MB for verify, which carries a PDF). |
| 422 | invalid_request, incomplete, more_than_owed, branding_missing, no_email, idempotency_mismatch | The body is wrong. The message names the field. |
| 429 | rate_limited, cap_reached, send_limit | Too many calls; wait for Retry-After. |
| 503 | switched_off, mail_unavailable, unavailable | Paused for now, with the reason. Try again later. |
Limits and caps
Each key may make so many calls a minute, set by the account's plan; X-RateLimit-Limit and X-RateLimit-Remaining say where you are. Over it, the answer is 429 with Retry-After in seconds.
Live calls also count towards a daily and a monthly allowance for the account. Where the cap is hard, calls past it get 429 cap_reached; where it is soft, they are answered with an AntaraX-Cap-Warning header. Test calls are never capped.
AntaraX can pause the API for everyone, for Lapis, for one account or for one key; paused calls get 503 switched_off with the reason.
Repeats
Send an Idempotency-Key header on a write (any string you choose, up to 120 characters, such as your own order id). If the same call is sent again with the same key and the same body, you get the first answer back, with Idempotent-Replayed: true, and nothing is made twice. The same key with a different body is refused with 422 idempotency_mismatch. A copy that arrives while the first is still running gets 409 idempotency_in_progress: wait for Retry-After and send it again.
Webhooks
Lapis can tell your systems when something happens, instead of you asking. Make an endpoint in Settings → Developers or with POST /webhook_endpoints, and each event is sent to it as a POST with a JSON body, one event a request.
| Event | When |
|---|---|
| lapis.document.issued | A document was issued |
| lapis.document.sent | A document was emailed |
| lapis.document.viewed | A customer opened a document's link |
| lapis.payment.recorded | A payment was recorded |
| lapis.document.cancelled | A document was cancelled |
| lapis.connection.created | An account connected one of your apps (Lapisan Connect) |
| lapis.connection.revoked | A connection to one of your apps ended (Lapisan Connect) |
| lapis.test | A test, sent from the console or the API |
Every event has the same envelope. data.object is the document as it was at that moment, in the same shape the API answers with; lapis.payment.recorded carries the invoice after the payment. Events come from everything that happens to the account's paper, in Lapis itself and through the API alike.
POST /hooks/lapis
Content-Type: application/json
AntaraX-Signature: t=1791015262,v1=5f1c0e...a94b
{
"id": "evt_2c8d41f0a9b34e6c8d7e1f2a3b4c5d6e",
"type": "lapis.payment.recorded",
"created": "2026-10-05T08:14:22Z",
"api_version": "2026-10-01",
"product": "lapis",
"tenant_id": "lacc_...",
"data": { "object": { "id": "ldoc_m1x2k3", "object": "document", "status": "paid", "owed": "0.00", ... } }
}Check the signature. AntaraX-Signature is t= the time in Unix seconds and v1= the HMAC-SHA256, in hex, of the time, a full stop and the raw body, keyed with the endpoint's secret (ax_whsec_…, shown once when the endpoint is made). Turn away anything that does not match or is more than five minutes old. In Node:
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
// Checks a delivery against the raw body, exactly as it arrived.
function verifyLapisWebhook(rawBody, header, secret, toleranceSeconds = 300) {
let t = null;
const signatures = [];
for (const part of (header ?? "").split(",")) {
const [k, v] = part.trim().split("=", 2);
if (k === "t") t = Number(v);
if (k === "v1") signatures.push(v);
}
if (!t || signatures.length === 0) return false;
// Older than five minutes: a replay, not a delivery.
if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
return signatures.some((s) => {
const given = Buffer.from(s, "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
});
}
const app = express();
// Keep this route's body raw: parsed and printed again, it would not match.
app.post("/hooks/lapis", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyLapisWebhook(raw, req.get("AntaraX-Signature"), process.env.LAPIS_WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(raw);
// At least once: skip an event.id you have handled already.
if (event.type === "lapis.payment.recorded") {
const invoice = event.data.object; // the document, as the API shows it
console.log(invoice.number, invoice.status, invoice.owed);
}
res.sendStatus(200); // answer quickly; do slow work afterwards
});- Answer with any 2xx within 10 seconds. Redirects are not followed: give the final address.
- Delivery is at least once and in no promised order: de-duplicate on
id, and trustcreatedand the object's own state over the order things arrive in. - A delivery that fails is tried again about 1 minute, 5 minutes, 30 minutes, 2, 6, 12 and 24 hours after the event, then given up. Retries run on a 10-minute schedule, so each comes at the first run after its time: up to 10 minutes later than the figure.
- An endpoint where 20 deliveries in a row fail is switched off, with the reason, until you switch it on again. Its log shows every try, with the code and the time, and Retry now.
- Test keys and test endpoints go together: a dry run's events go to test endpoints only, and live events to live endpoints only.
- Events and their deliveries are kept 30 days;
GET /eventslists them for catching up.
Lapisan Connect: act for other Lapis accounts
For software that works for many businesses, such as a point of sale, a booking system or an accounting tool. Each business keeps its own Lapis account and approves your app once; from then on you call with your own key and say which account with one header. Their paper stays theirs: numbered in their sequence, in their look, emailed from them. Lapisan Connect is part of Lapisan, the AntaraX developer platform; Lapis is the first product on it, and others follow the same steps.
- Register an app in Lapis, under Settings → Developers → Lapisan Connect (your account needs the API on): its name, website and logo, the addresses people go back to (matched exactly;
http://localhostonly for test), and what it asks each account for. Itsclient_idisapp_…. - Send the owner to the authorize link. The page is Lapisan's, on Lapis's host for now (it moves to
lapisan.antarax.ai/connectonce AntaraX has one shared sign-in). They sign in to Lapis by email, or get a free account on the spot, and see your app, the business it will act for, each permission in plain words, and test or live. It never shows inside a frame. - Approve sends them to your
redirect_uriwithcodeand yourstate; Cancel witherror=access_denied. A bad link (unknown app, an address you did not register, no state) is explained on the page and sends nobody anywhere. - Swap the code on your server with
POST /connect/tokenand your key. The answer is the connection, with theaccountid to keep. - Call for them with
AntaraX-Account: <account>. The call runs as their account, with what both your key and their approval allow.
1. Your app sends the account's owner to:
https://angka.antarax.ai/lapis/connect/authorize?client_id=app_...
&redirect_uri=https://yourapp.com/lapis/callback
&scope=lapis:documents:read lapis:documents:write
&state=<a fresh random value you keep>
&mode=live (or test)
2. They sign in to Lapis (a new address gets its free account),
see your app and what it asks for, and press Approve.
3. Lapis sends them back to:
https://yourapp.com/lapis/callback?code=axc_...&state=<yours>
(Cancel: ?error=access_denied&state=<yours>)
4. Your server checks state, then swaps the code (10 minutes, once):
POST https://api.antarax.ai/lapis/v1/connect/token
{ "code": "axc_...", "redirect_uri": "..." }
-> { "object": "connection", "account": "lap_...", ... }
5. Every call for them: your own key, and their account.
Authorization: Bearer ax_live_... (yours)
AntaraX-Account: lap_... (theirs)curl https://api.antarax.ai/lapis/v1/documents \
-H "Authorization: Bearer $PARTNER_KEY" \
-H "AntaraX-Account: lap_mh2k3x9" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8812" \
-d '{ "type": "invoice", "customer": { "name": "Kedai Runcit Ali" }, "lines": [ { "description": "Banner", "unit_price": "150.00" } ] }'- Test and live. A connection is one or the other, as the link said (
mode=test), and works only with keys of the same mode. A test connection reads the account's real paper, and everything your test key makes for it is a dry run, as always. - Limits and paying. Calls you make for connected accounts count against your own plan (rate per key, daily and monthly caps), not theirs. While the pilot is free, nothing is charged.
- Repeats. An
Idempotency-Keyis kept per account you act for, so the same key sent for two accounts runs once for each. - Your own things stay yours. Webhook endpoints, events and connections are always your own account's; with
AntaraX-Accountthey answer 403connection_scope. - Disconnects. The owner can disconnect in Lapis, under Settings → Connected apps (with a reason, which you are sent); you can with
DELETE /connections/{id}; AntaraX can, and can pause an app. After that, calls get 403connection_missing. A suspended account turns every connected call away. - Webhooks for connected accounts. Their events also go to your own endpoints in the same mode, when the connection may read what they carry:
tenant_idis their account, and the envelope adds"connection": "conn_…".lapis.connection.createdandlapis.connection.revokedgo to your endpoints only, carrying the connection. - Errors. 400
invalid_account(not an account id), 400invalid_code(a code unknown, used, late, for another app or address, or the other mode), 403connection_missing(no working connection in the key's mode, or the app is paused), 403connection_scope(not granted).
Use Lapis from Claude or another AI assistant
Lapis is also an MCP server, on the same keys: https://api.antarax.ai/lapis/v1/mcp. An assistant that speaks MCP over HTTP can list and read documents, make and send them, record payments, cancel, read customers and their statements, and verify paper. Each tool needs the same permission as its endpoint here, and counts the same; with a test key every write is a dry run, which is the safe way to start.
For Claude Code, in the project's .mcp.json (or the same entry in another client's settings):
{
"mcpServers": {
"lapis": {
"type": "http",
"url": "https://api.antarax.ai/lapis/v1/mcp",
"headers": { "Authorization": "Bearer ${LAPIS_KEY}" }
}
}
}Or from the command line:
claude mcp add --transport http lapis https://api.antarax.ai/lapis/v1/mcp --header "Authorization: Bearer ax_test_..."The tools are list_documents, get_document, create_document, send_document, record_payment, cancel_document, list_customers, customer_statement and verify_document, each with the input this reference describes. Give the assistant a key with only the permissions it needs: a key that may only read cannot be talked into issuing anything.
POST/documents
Needs lapis:documents:write
Make a document: a quotation, proforma, invoice, credit or debit note, purchase order, payment voucher or delivery order. It is issued in the account's saved look, numbered, signed and given its customer link.
- customer.name and at least one line are needed. Each line has a description and a unit_price as a string; quantity defaults to 1, tax_percent and discount_percent to 0.
- number defaults to the next in the account's own sequence. A number already used is refused with 409 number_taken, and the message names the next free one.
- date defaults to today where the business is. Give due_date, or due_in_days; otherwise an invoice is due in 30 days and a quotation is valid for 14.
- currency is an ISO code and defaults to the account's own. issue: false keeps a draft to finish in Lapis instead.
- send_to_customer: true emails it to customer.email once it is issued. If it could not go, the answer says why in notice.
- Accounts where paper is approved by someone first answer 409 approval_required: send issue: false and approve it in Lapis.
Request
curl https://api.antarax.ai/lapis/v1/documents \
-H "Authorization: Bearer $LAPIS_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1001" \
-d '{
"type": "invoice",
"customer": { "name": "Kedai Runcit Ali", "email": "ali@kedai.my" },
"lines": [
{ "description": "Roll-up banner", "quantity": 3, "unit": "pcs", "unit_price": "150.00", "tax_percent": 8 }
],
"due_in_days": 14,
"send_to_customer": true
}'Answer
201 Created
{
"id": "ldoc_m1x2k3",
"object": "document",
"type": "invoice",
"number": "INV-2026-0088",
"status": "issued",
"date": "2026-10-03",
"due_date": "2026-10-17",
"currency": "MYR",
"customer": { "id": "lcus_5f0c3a9be21d7e44", "object": "customer", "name": "Kedai Runcit Ali", "email": "ali@kedai.my", ... },
"lines": [
{ "description": "Roll-up banner", "quantity": 3, "unit": "pcs", "unit_price": "150.00",
"discount_percent": 0, "tax_percent": 8, "amount": "486.00" }
],
"subtotal": "450.00", "discount": "0.00", "tax": "36.00", "total": "486.00",
"paid": "0.00", "owed": "486.00",
"url": "https://angka.antarax.ai/d/k7m2p9qrst4x",
"pdf": "https://api.antarax.ai/lapis/v1/documents/ldoc_m1x2k3/pdf",
"sent_to_customer_at": "2026-10-03T02:14:09.000Z",
...
}GET/documents
Needs lapis:documents:read
This account's documents, drafts included, newest first.
- Filters: type, status (draft, awaiting_approval, issued, part_paid, paid, cancelled, replaced), customer (part of the name, any case), from and to (issue dates).
- limit is 1 to 100, 25 by default. Pass next back as cursor for the following page; it is null on the last.
Request
curl "https://api.antarax.ai/lapis/v1/documents?type=invoice&status=part_paid&limit=50" -H "Authorization: Bearer $LAPIS_KEY"Answer
200 OK
{
"data": [ { "id": "ldoc_m1x2k3", "object": "document", "number": "INV-2026-0088", ... } ],
"next": "WyIyMDI2LTEwLTAzVDAyOjE0OjA5LjAwMFoiLCJsZG9jX20xeDJrMyJd"
}GET/documents/{id}
Needs lapis:documents:read
One document. An invoice comes with its payments. A document of another account is 404 not_found, the same as one that does not exist.
Request
curl https://api.antarax.ai/lapis/v1/documents/ldoc_m1x2k3 -H "Authorization: Bearer $LAPIS_KEY"Answer
200 OK
{
"id": "ldoc_m1x2k3", "status": "part_paid", "total": "486.00", "paid": "200.00", "owed": "286.00",
"payments": [
{ "id": "lpay_m1y9a0", "object": "payment", "amount": "200.00", "currency": "MYR",
"paid_on": "2026-10-05", "method": "duitnow", "reference": "88213", "receipt": null, ... }
],
...
}GET/documents/{id}/pdf
Needs lapis:documents:read
The PDF, as issued, with its signed record inside. A draft is drawn with DRAFT across every page and no record.
Request
curl https://api.antarax.ai/lapis/v1/documents/ldoc_m1x2k3/pdf -H "Authorization: Bearer $LAPIS_KEY" -o INV-2026-0088.pdfAnswer
200 OK
Content-Type: application/pdf
%PDF-1.7 ...POST/documents/{id}/send
Needs lapis:documents:write
Email an issued document, to the customer's address on it or to another. The same email the customer gets from Lapis.
- One address receives at most 20 documents a day; over that is 429 send_limit.
Request
curl https://api.antarax.ai/lapis/v1/documents/ldoc_m1x2k3/send \
-H "Authorization: Bearer $LAPIS_KEY" -H "Content-Type: application/json" \
-d '{ "to": "accounts@kedai.my" }'Answer
200 OK
{ "object": "delivery", "sent_to": "accounts@kedai.my", "document": { "id": "ldoc_m1x2k3", ... } }POST/documents/{id}/payments
Needs lapis:payments:write
Money that arrived against an invoice. amount is what came this time, not the running total; the invoice's status follows the arithmetic.
- More than is owed is refused with 422 more_than_owed.
- method is one of bank_transfer, duitnow, qr_payment, cash, card, cheque, online_payment, other. paid_on defaults to today where the business is.
Request
curl https://api.antarax.ai/lapis/v1/documents/ldoc_m1x2k3/payments \
-H "Authorization: Bearer $LAPIS_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: payment-88213" \
-d '{ "amount": "200.00", "paid_on": "2026-10-05", "method": "duitnow", "reference": "88213" }'Answer
201 Created
{
"object": "payment_recorded",
"payment": { "id": "lpay_m1y9a0", "amount": "200.00", "currency": "MYR", "paid_on": "2026-10-05", ... },
"document": { "id": "ldoc_m1x2k3", "status": "part_paid", "paid": "200.00", "owed": "286.00", ... }
}POST/documents/{id}/cancel
Needs lapis:documents:write
Cancel an issued document. It keeps its place in the record, its number is freed, it is never chased, and its link says it was cancelled.
- A document with money recorded against it is refused with 409 paid: make a credit note instead, so the record stays true.
Request
curl https://api.antarax.ai/lapis/v1/documents/ldoc_m1x2k3/cancel \
-H "Authorization: Bearer $LAPIS_KEY" -H "Content-Type: application/json" \
-d '{ "reason": "Made twice" }'Answer
200 OK
{ "id": "ldoc_m1x2k3", "object": "document", "status": "cancelled", ... }GET/customers
Needs lapis:customers:read
Everyone this account has made paper for, most recent first, as they were last written.
- Lapis keeps no separate customer records: a customer is who the paper names. Their id is lcus_ and the first 16 hex characters of SHA-256 over the name, trimmed, with its spaces collapsed and in lower case, so the same name always has the same id.
Request
curl https://api.antarax.ai/lapis/v1/customers -H "Authorization: Bearer $LAPIS_KEY"Answer
200 OK
{
"data": [ { "id": "lcus_5f0c3a9be21d7e44", "object": "customer", "name": "Kedai Runcit Ali",
"email": "ali@kedai.my", "phone": "", "address": "", "registration": "" } ],
"next": null
}GET/customers/{id}/statement
Needs lapis:customers:read
A customer's statement of account: what they owed coming in, every invoice, payment and note in the period with the balance after it, what they owe at the end, and how long each part has been owing.
- from defaults to their first invoice, to to today. One statement adds up one currency: currency picks it, and currencies lists every one they have invoices in.
Request
curl "https://api.antarax.ai/lapis/v1/customers/lcus_5f0c3a9be21d7e44/statement?from=2026-07-01&to=2026-09-30" -H "Authorization: Bearer $LAPIS_KEY"Answer
200 OK
{
"object": "statement",
"customer": { "id": "lcus_5f0c3a9be21d7e44", "name": "Kedai Runcit Ali" },
"currency": "MYR", "currencies": ["MYR"],
"from": "2026-07-01", "to": "2026-09-30",
"opening": "350.00", "closing": "836.00",
"entries": [
{ "date": "2026-09-20", "kind": "invoice", "description": "Invoice INV-2026-0088, due 4 Oct 2026",
"amount": "486.00", "balance": "836.00" }
],
"ageing": { "current": "486.00", "days_1_30": "0.00", "days_31_60": "0.00", "days_61_90": "350.00", "over_90": "0.00" }
}POST/verify
Needs lapis:documents:read
Is this a genuine Lapis document? Send its link, or the PDF as base64. A PDF is checked by the signed record inside it and against the paper Lapis issued; a link must be on a Lapis address. Any Lapis document can be checked, not only this account's.
Request
curl https://api.antarax.ai/lapis/v1/verify \
-H "Authorization: Bearer $LAPIS_KEY" -H "Content-Type: application/json" \
-d "{ \"pdf\": \"$(base64 -w0 INV-2026-0088.pdf)\" }"Answer
200 OK
{
"genuine": true, "source": "pdf", "reason": null, "cancelled": false,
"document": { "type": "invoice", "number": "INV-2026-0088", "date": "2026-10-03", "due_date": "2026-10-17",
"total": "486.00", "currency": "MYR", "customer": "Kedai Runcit Ali", "issuer": "Kilang Kertas Sdn Bhd" }
}POST/webhook_endpoints
Needs lapis:webhooks:write
Make a webhook endpoint: an https address on the public internet that Lapis sends events to. A test key makes a test endpoint, which gets test events only; a live key makes a live one. The answer carries the signing secret, the one time it is shown.
- events lists the types to send; left out or empty, every one. See Webhooks above for the list.
- An account has at most 10 endpoints; another is refused with 409 endpoint_limit. Private, loopback and link-local addresses, localhost, .local and .internal are refused.
- GET /webhook_endpoints lists them, GET and PATCH /webhook_endpoints/{id} read and change one (url, description, events, enabled), and DELETE removes it with its log.
Request
curl https://api.antarax.ai/lapis/v1/webhook_endpoints \
-H "Authorization: Bearer $LAPIS_KEY" -H "Content-Type: application/json" \
-d '{ "url": "https://shop.example.com/hooks/lapis", "events": ["lapis.payment.recorded", "lapis.document.viewed"] }'Answer
201 Created
{
"id": "whe_4b1e9c07a2d35f86", "object": "webhook_endpoint",
"url": "https://shop.example.com/hooks/lapis", "description": "",
"events": ["lapis.payment.recorded", "lapis.document.viewed"],
"mode": "live", "enabled": true, "disabled_reason": null, "failure_streak": 0,
"secret_prefix": "ax_whsec_k7Qm",
"secret": "ax_whsec_k7Qm...",
"created_at": "2026-10-03T02:20:11.000Z", "updated_at": "2026-10-03T02:20:11.000Z"
}POST/webhook_endpoints/{id}/test
Needs lapis:webhooks:write
Send the endpoint a lapis.test event now, whatever it subscribes to, and wait for the answer, so you can check your signature code before anything real happens.
Request
curl -X POST https://api.antarax.ai/lapis/v1/webhook_endpoints/whe_4b1e9c07a2d35f86/test -H "Authorization: Bearer $LAPIS_KEY"Answer
200 OK
{
"object": "webhook_test", "delivered": true, "status_code": 200, "error": null, "duration_ms": 182,
"event": { "id": "evt_9c2f...", "object": "event", "type": "lapis.test", ... }
}GET/events
Needs lapis:webhooks:write
Recent events in the key's mode, newest first, exactly as they were sent. Use it to catch up after your endpoint was down. GET /events/{id} reads one.
- Filter with type. limit is 1 to 100; pass next back as cursor. Events are kept 30 days, and only while the account has an endpoint in that mode.
Request
curl "https://api.antarax.ai/lapis/v1/events?type=lapis.payment.recorded&limit=10" -H "Authorization: Bearer $LAPIS_KEY"Answer
200 OK
{
"data": [ { "id": "evt_2c8d...", "object": "event", "type": "lapis.payment.recorded", "created": "2026-10-05T08:14:22Z", ... } ],
"next": null
}POST/connect/token
Needs lapis:connections:write
Lapisan Connect: swap the code an account's approval sent to your redirect_uri for the connection. Your own key, never the account's.
- Within 10 minutes, once. The code must be for one of your apps, with the same redirect_uri, and swapped with a key in the same mode (a test code with a test key). Anything else is 400 invalid_code, and the code is spent.
- Check the state you sent first: it is yours, and it is how you know the answer is for the person you sent.
Request
curl https://api.antarax.ai/lapis/v1/connect/token \
-H "Authorization: Bearer $PARTNER_KEY" \
-H "Content-Type: application/json" \
-d '{ "code": "axc_...", "redirect_uri": "https://yourapp.com/lapis/callback" }'Answer
200 OK
{
"object": "connection",
"id": "conn_5d1f0a9e2b7c4e11",
"app": "app_8c2e41f0a9b34e6c",
"account": "lap_mh2k3x9",
"account_name": "Kedai Runcit Ali",
"scopes": ["lapis:documents:read", "lapis:documents:write"],
"mode": "live",
"status": "active",
"created": "2026-10-05T08:14:22Z",
...
}GET/connections
Needs lapis:connections:read
The accounts your apps are connected to, in the key's mode, newest first. GET /connections/{id} reads one.
- Filter with app (a client_id); status=all includes ended ones. limit is 1 to 100; pass next back as cursor.
Request
curl "https://api.antarax.ai/lapis/v1/connections?limit=50" -H "Authorization: Bearer $PARTNER_KEY"Answer
200 OK
{ "data": [ { "object": "connection", "id": "conn_5d1f...", "account": "lap_mh2k3x9", ... } ], "next": null }DELETE/connections/{id}
Needs lapis:connections:write
End a connection from your side. Your app can no longer act for that account until its owner approves it again; your endpoints get lapis.connection.revoked.
Request
curl -X DELETE https://api.antarax.ai/lapis/v1/connections/conn_5d1f0a9e2b7c4e11 -H "Authorization: Bearer $PARTNER_KEY"Answer
200 OK
{ "object": "connection", "id": "conn_5d1f...", "status": "revoked", "revoked_by": "partner", ... }The whole shape
Every field of every request and answer is in openapi.json, an OpenAPI 3.1 document made from the same schemas the API checks with. Load it into Postman, Insomnia or a client generator. It needs no key.