Introduction
An AI agent that books a flight, renews a subscription, or restocks
inventory on a user's behalf needs a way to spend money — but handing an
agent a raw credit card number is not a payments primitive, it's a
liability. moyasar-ac is a proof-of-concept for the missing piece:
a payment-mandate service that sits between an agent and a real charge,
turning "the agent wants to spend money" into a chain of explicit,
revocable, cryptographically-verifiable consent.
The shape of it:
- An agent registers itself (
Agents) and gets credentials. - The agent asks moyasar-ac to create an Intent Mandate — a signed, capped spending authorization scoped to one user, one currency, one ceiling amount, and (optionally) a domain-specific intent like a flight booking.
- The mandate is not active yet. The user must open a hosted approval link, authenticate, and sign the mandate with a WebAuthn passkey before a single unit of currency can move.
- Once approved, a merchant can draw against the mandate — a signed, replay-protected charge request bounded by the mandate's cap.
- Every step is appended to a hash-chained audit log, so the full history of who authorized what, and when, is independently verifiable.
Domain-specific intents are pluggable: the flight-booking demo, for
instance, lets an agent name a carrier — Saudia or Emirates — either in
the mandate's structured airline field or in plain language (e.g. "book
an Emirates flight to London"), and the correct merchant is resolved
automatically.
This reference documents the REST API a developer integrates against,
the hosted approval flow a real human completes, and the MCP tool layer
that lets an AI agent (Claude, an LLM in LM Studio, or any MCP-capable
client) drive the whole flow through natural conversation.
Authentication
Agents authenticate with OAuth2 client-credentials. You get a
client_id/client_secret pair from registering an agent,
then exchange them for a short-lived bearer token.
All examples on this page use http://localhost:3000, the sandbox host
this PoC runs on — substitute your own deployment's host.
Get a bearer token
curl -X POST http://localhost:3000/oauth/token \
-H "Content-Type: application/json" \
-d '{
"grant_type": "client_credentials",
"client_id": "ag_YlTyCQWgXSwI6ElAyJsYEKbY",
"client_secret": "your-agent-client-secret"
}'
import httpx
resp = httpx.post("http://localhost:3000/oauth/token", json={
"grant_type": "client_credentials",
"client_id": "ag_YlTyCQWgXSwI6ElAyJsYEKbY",
"client_secret": "your-agent-client-secret",
})
print(resp.json())
require "net/http"
require "json"
uri = URI("http://localhost:3000/oauth/token")
res = Net::HTTP.post(uri, {
grant_type: "client_credentials",
client_id: "ag_YlTyCQWgXSwI6ElAyJsYEKbY",
client_secret: "your-agent-client-secret"
}.to_json, "Content-Type" => "application/json")
puts JSON.parse(res.body)
Response
200 OK
{
"access_token": "eyJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 300
}
Send the token on every subsequent API call:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Tokens expire after 300 seconds (5 minutes) — request a new one when
create_intent_mandate or any bearer-authenticated call returns
401 unauthorized.
Authentication errors
| Status | error |
Meaning |
|---|---|---|
400 |
unsupported_grant_type |
grant_type must be exactly "client_credentials" |
401 |
invalid_client |
client_id/client_secret don't match an active agent |
Scopes
Every token is issued with the mandates:write scope. Endpoints that
require a scope your token doesn't have return 403 {"error":.
"insufficient_scope"}
Agents
Register a new spending agent. Do this once per agent identity — the
returned client_secret is used for every Authentication
token request afterward.
Register an agent
curl -X POST http://localhost:3000/v1/agents \
-H "Content-Type: application/json" \
-d '{"name": "SkyBooker"}'
import httpx
resp = httpx.post("http://localhost:3000/v1/agents", json={"name": "SkyBooker"})
print(resp.json())
require "net/http"
require "json"
uri = URI("http://localhost:3000/v1/agents")
res = Net::HTTP.post(uri, { name: "SkyBooker" }.to_json,
"Content-Type" => "application/json")
puts JSON.parse(res.body)
By default moyasar-ac mints and retains the agent's Ed25519 signing keypair
itself (there's no real external process to hold it for). A counterparty that
holds its own private key — such as the ac-mcp agent, which signs cart terms
via sign_cart — can instead supply just its public half at registration
time, so moyasar-ac never sees the private key:
curl -X POST http://localhost:3000/v1/agents \
-H "Content-Type: application/json" \
-d '{"name": "SkyBooker", "public_key": "xLwNQML3k4hfGPVzHzqr_9yKfETvsdYyGCZczsscJQ0"}'
Request
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | yes | Display name for the agent. Must not be blank. |
public_key |
string | no | The agent's Ed25519 public key, base64url-encoded without padding (32 raw bytes). When omitted, moyasar-ac mints and retains a keypair for the agent. When supplied, only this public half is stored — moyasar-ac never holds or generates the private key, so the agent (not moyasar-ac) is responsible for producing valid signatures with it later (e.g. via the MCP sign_cart tool). Must decode to exactly 32 bytes and be a structurally valid Ed25519 verify key, or registration fails with 422. |
Agent registration response
{
"agent_id": "43b53ae5-caf7-464c-a00d-e89ffb154d21",
"name": "SkyBooker",
"client_id": "ag_YlTyCQWgXSwI6ElAyJsYEKbY",
"client_secret": "9k2mZ...40-character-random-string"
}
client_secretis shown exactly once and cannot be recovered.
The server stores only a bcrypt digest of it — there is no "forgot my
secret" endpoint. Store it securely the moment you receive it.
Agent errors
| Status | error |
Meaning |
|---|---|---|
404 |
— (empty body) | Self-registration is disabled in this environment |
429 |
rate_limited |
More than 10 registrations from this IP in the current minute |
422 |
name_required |
name was blank |
422 |
(message varies, e.g. "public_key: public_key must decode to 32 bytes...") |
Supplied public_key isn't valid base64url or doesn't decode to a structurally valid Ed25519 public key |
Catalog
Closed discovery endpoint: lets an authenticated agent browse the
in-stock catalog of products offered by merchants that have completed
onboarding (portal_status: "approved") through the merchant portal, an
internal application-review flow with no dedicated public docs section
today. A product only appears here if it's active, has stock
available (stock_on_hand - reserved_count > 0), and belongs to an
approved merchant — a merchant
that's still pending or has been rejected has no products surfaced
here, even if it has some on file.
Requires a bearer token with the mandates:write scope (see
Authentication).
List catalog products
curl "http://localhost:3000/v1/catalog?domain_id=retail_purchase¤cy=SAR" \
-H "Authorization: Bearer $ACCESS_TOKEN"
import httpx
resp = httpx.get(
"http://localhost:3000/v1/catalog",
params={"domain_id": "retail_purchase", "currency": "SAR"},
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.json())
require "net/http"
require "json"
uri = URI("http://localhost:3000/v1/catalog")
uri.query = URI.encode_www_form(domain_id: "retail_purchase", currency: "SAR")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{access_token}")
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
domain_id |
string | no | Exact match, e.g. generic_purchase, flight_booking, hotel_booking, car_rental, activity_booking, retail_purchase. Omit to return products across all domains. |
currency |
string | no | Exact match, one of SAR, AED, USD. Omit to return products in any currency. |
Catalog response
{
"products": [
{
"id": "9c3b6f1a-2e4d-4a7b-9f0e-1234567890ab",
"merchant_id": "33333333-3333-3333-3333-333333333333",
"title": "In stock",
"description": null,
"price_minor": 100,
"currency": "SAR",
"domain_id": "generic_purchase",
"sku": "IN-1",
"available": 3,
"merchant_name": "SinglePage (test)",
"image_data_uri": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i..."
}
]
}
Each entry in products is one in-stock, active product:
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | The product's ID. |
merchant_id |
string (UUID) | The owning merchant. |
title |
string | Product name. |
description |
string or null |
Optional longer description. |
price_minor |
integer | Unit price, in minor currency units. |
currency |
string | SAR, AED, or USD. |
domain_id |
string | Which domain plugin this product belongs to. |
sku |
string | Merchant-assigned SKU, unique per merchant. |
available |
integer | Units currently purchasable: stock_on_hand - reserved_count, not raw stock_on_hand. Units already reserved by someone else's in-flight draw are not buyable, so they're excluded here even though they're still physically on the shelf. Always positive — a product is dropped from products entirely once this would reach zero (see below). |
merchant_name |
string | The owning merchant's display name, for a picker row to show who's selling. |
image_data_uri |
string or null |
Inline product artwork, or null if the product has none (a picker renders a placeholder in that case). Always a data:image/svg+xml, data:image/png, or data:image/jpeg base64 URI — never a remote URL — because the response can end up rendered by a widget that loads nothing from the network. Capped at 16KB of the raw string's bytes (Product::MAX_IMAGE_BYTES), and that byte payload must itself decode as valid base64, for every MIME type. |
Where the safety guarantee for this field actually lives. SVG is
executable markup, so a hostile image_data_uri is a real concern — but the
guarantee here is the render path, not a content scan. The MCP Apps
widget renders artwork only as <img src="data:...">, never as inline
<svg> markup, a CSS background-image, or an <object>/<embed>/
<iframe>; that invariant is enforced on the widget's own source by
test_artwork_only_ever_renders_via_img_src_data_uri
(tools/ac-mcp/tests/test_mcp_apps.py), and confirmed with a live Chromium
network trace to make zero network requests for an <img>-rendered SVG
regardless of its content. That is what actually keeps a hostile payload
inert.
The widget also enforces the scheme itself, rather than trusting this
API to have done it: a value that is not a data:image/ URI is never put in
an src at all — it falls back to the same neutral placeholder a product
with no artwork gets. So an https://… value arriving here by any route
produces no image request from inside the host's page.
On top of that, Product runs a best-effort content check on write —
rejecting an SVG containing a <script>/<foreignObject> element, an
event-handler attribute, a DOCTYPE declaration, or an href/src/url(...)
reference outside the document (a bare #fragment or a data:image/...
value is still allowed; other data: subtypes are not). This check is
not a security boundary and does not claim to be one: it is a
text-level scan, not a CSS- or SMIL-aware parser, and known gaps — CSS
escape sequences, an unquoted @import, and a SMIL <set attributeName= that installs a reference only when a browser's animation
"href" to="...">
engine runs it — are left open deliberately rather than chased with an
ever-growing set of regexes. It exists to catch accidents and unsophisticated
attempts cheaply, on top of the render-path guarantee above, not instead of
it.
stock_on_hand, reserved_count, active, and the owning merchant's
portal_status are never included in the response as raw values — they
only affect which products are eligible to be listed in the first
place. available is the one number derived from them that is
surfaced, precisely because a picker needs to show "only N left."
A product is listed only while stock_on_hand - reserved_count > 0. A
product with stock on the shelf but every unit reserved by an
in-flight draw is not listed at all, rather than listed with
available: 0.
Catalog errors
| Status | error |
Meaning |
|---|---|---|
401 |
unauthorized |
Missing/invalid bearer token |
403 |
insufficient_scope |
Token is valid but lacks the mandates:write scope |
Inventory feed
Instead of typing products into the merchant portal one at a time, you
can publish a REST feed — a single https:// endpoint that returns
your catalog as JSON — and moyasar-ac will poll it and keep your
catalog listings in step with it.
You build the endpoint; we call it. There is no API to push a catalog to
us in v1, and nothing about this section is something an agent ever
sees: the feed is the merchant-side contract, and the
Catalog endpoint is what agents read afterwards.
Before you connect one, read Stock is overwritten, not adjusted.
A feed whosestock_on_handdoes not already account for units sold
through moyasar-ac will oversell — every poll re-inflates our count
back to yours, and the difference is sold twice. It is the one part of
this contract that costs money to get wrong.
Connecting a feed
Feeds are connected from the merchant portal at /merchant/inventory —
there is no API for this in v1. You supply the feed's URL and,
optionally, a bearer token. A merchant may have one feed connected
at a time; connecting a second is refused.
The connect and edit forms refuse a URL that:
- doesn't use
https:// - carries userinfo (
https://user:pass@host/...) - uses any port other than 443
- resolves (at connect time, and again on every page of every poll) to a private, loopback, link-local, CGNAT, multicast or otherwise reserved address, or to one of moyasar-ac's own hostnames
- is longer than 2,000 characters
moyasar-ac never follows a redirect to your feed: a 3xx is a failed
poll, not a hop to follow. This is the same outbound guard the
merchant webhook dispatcher uses — the feed
poller extends that client rather than copying it.
Disconnecting a feed does not delete the products it created. They
stay exactly as they are, on sale, and become hand-entered products you
own in the portal from that moment on — keeping the price and stock from
the last good sync, since nothing refreshes them while no feed is
connected.
They are also marked as having come from a disconnected feed. When you
connect a feed again, the Inventory screen offers to hand them to it in
one click; until you accept, the new feed reports each of them as a SKU
collision, because a feed never overwrites a product you own by hand.
Accepting hands over every product left by a disconnected feed, not
only the SKUs the new feed lists — anything it does not list is taken
off sale on the first complete traversal, exactly as it would have been
had you never disconnected. Products you typed yourself are never
offered and never touched.
Authenticating our requests
Every request we make to your feed carries:
| Header | Value |
|---|---|
Accept |
application/json |
Authorization |
Bearer <your token> — only if you set a token; the header is omitted entirely when you didn't |
A public feed is a legitimate configuration. Leave the token field
empty and we send no Authorization header at all. If your catalog
isn't sensitive, an unauthenticated endpoint is a perfectly good answer
and there is nothing to store or rotate.
If you do set a token, it is stored encrypted and never shown back to
you. The portal's token field always renders empty, and leaving it
empty when you edit the feed means keep the token I already have — it
does not clear it. Removing a token is a separate, explicit control.
Changing the feed's host drops the stored token. If you edit the URL
so that its hostname changes, the token is discarded and the portal says
so; re-enter it if the new host needs one. (Editing only the path or
query string on the same host keeps it.) This is deliberate: without it,
repointing a feed at another host would have delivered your secret to
that host in an Authorization header.
The feed response
Answer 200 with a JSON object. Anything else — a non-2xx, a body
that isn't JSON, or an object that fails the envelope rules below —
fails the whole run and changes nothing in your catalog.
Envelope fields
| Field | Type | Required | Constraint |
|---|---|---|---|
version |
integer | yes | Must be exactly 1. The JSON number 1, not the string "1" |
items |
array | yes | May be empty, but see the outcome table — a run with no usable items deactivates nothing |
generated_at |
string or null |
no | ISO 8601, at most 64 characters. Shown to you in the portal so "my feed is fine, your sync is old" is answerable. On a paginated feed the first page's value is the one recorded, and a value that isn't a real date is recorded as blank rather than failing the run |
next |
string or null |
no | Absolute URL of the next page, or null/omitted on the last page. See Pagination |
Item fields
Field names and value domains are deliberately identical to the product
columns the Catalog exposes. There is no mapping layer in
v1: what you send is what is stored.
| Field | Type | Required | Constraint |
|---|---|---|---|
sku |
string | yes | 1–128 characters. No control characters. No leading or trailing whitespace. Unique per merchant — see SKU collisions |
title |
string | yes | 1–512 characters, no control characters |
price_minor |
integer | yes | Between 1 and 2147483647. Minor units (halalas, fils, cents). A JSON string or float is refused, not coerced |
currency |
string | yes | One of SAR, AED, USD, and one of your own account's supported currencies (if your account restricts them; an unrestricted account accepts all three) |
domain_id |
string | yes | One of generic_purchase, flight_booking, hotel_booking, car_rental, activity_booking, retail_purchase |
stock_on_hand |
integer | yes | Between 0 and 2147483647. Read Stock is overwritten, not adjusted before you fill this in |
fulfillment_mode |
string | yes | digital or pickup |
active |
'active' must be the JSON value true or false, not %{value} — that is text, not a boolean. |
no | Defaults to true when the key is absent. A strict boolean — see below |
description |
string or null |
no | At most 4,000 characters |
cancellation_policy_text |
string or null |
no | At most 4,000 characters. Copied into the signed mandate and shown on the approval page — see below |
return_policy_text |
string or null |
no | At most 4,000 characters. Same |
Three of these behave in a way that surprises people:
active is a strict boolean. true and false only. The string
"false", the number 0, and "" are all rejected — the item is
refused with a reason naming the value, and the product's current
active state is left alone. This matters because it is the one field
that decides whether an agent can buy the listing: if a stringified
"false" were coerced, the most common serializer bug in the world
(PHP, XML-derived and CSV-to-JSON feeds routinely stringify booleans)
would silently leave a withdrawn product on sale, with a clean
"succeeded" run in your portal and nothing to notice. If your serializer
emits "true"/"false", fix it at the source or your listings cannot
be taken off sale.
A sku with stray whitespace or a control character is refused, not
cleaned up. "COFFEE-1KG " (trailing space) is rejected rather than
trimmed to "COFFEE-1KG". Silently stripping would let two different
rows in your feed — "ABC" and "ABC " — collapse onto one product,
with the last one written winning; and a feed that drifts on whitespace
from one poll to the next would create a look-alike product while the
real one was taken off sale. Refusing is the only behaviour that can't
quietly damage a live listing.
Omitting an optional field blanks it. These are absolute writes, not
patches: if description was set and your next poll omits it, the
description becomes empty. The same is true of
cancellation_policy_text and return_policy_text — and those two are
not cosmetic. They are copied into the mandate's signed
intent_payload and rendered on the hosted approval page that a human
authorizes with a passkey. Dropping them from a poll removes those terms
from the consent ceremony for every mandate created afterwards. Send
them on every page of every poll, not just when they change.
A conforming feed response
{
"version": 1,
"generated_at": "2026-08-04T09:00:00Z",
"items": [
{
"sku": "COFFEE-1KG",
"title": "Arabica Roast 1kg",
"price_minor": 8900,
"currency": "SAR",
"domain_id": "generic_purchase",
"stock_on_hand": 12,
"fulfillment_mode": "pickup",
"description": "Single origin, roasted weekly.",
"active": true,
"cancellation_policy_text": "Cancel free of charge up to 24 hours before pickup.",
"return_policy_text": "Unopened bags may be returned within 14 days."
},
{
"sku": "EBOOK-ROASTING",
"title": "Home Roasting, 2nd edition",
"price_minor": 4500,
"currency": "SAR",
"domain_id": "generic_purchase",
"stock_on_hand": 999,
"fulfillment_mode": "digital",
"active": false
}
],
"next": null
}
This is exactly the request we make — reproduce it against your own
endpoint to check what we'll see:
curl -sS "https://feeds.example.com/moyasar-ac/catalog" \
-H "Accept: application/json" \
-H "Authorization: Bearer $FEED_TOKEN" # omit this line for a public feed
A minimal conforming responder, with pagination:
from flask import Flask, jsonify, request
app = Flask(__name__)
PAGE = 500
@app.get("/moyasar-ac/catalog")
def catalog():
cursor = int(request.args.get("cursor", 0))
rows = load_products(offset=cursor, limit=PAGE) # your own query
has_more = len(rows) == PAGE
return jsonify({
"version": 1,
"generated_at": utcnow_iso8601(),
"items": [
{
"sku": r.sku,
"title": r.title,
"price_minor": int(r.price_minor),
"currency": r.currency,
"domain_id": "generic_purchase",
# Units you are willing to sell through moyasar-ac, with
# everything already sold through it deducted.
"stock_on_hand": int(r.available_to_moyasar_ac),
"fulfillment_mode": "pickup" if r.physical else "digital",
"description": r.description,
"active": bool(r.on_sale), # a real bool, never "false"
"cancellation_policy_text": r.cancellation_terms,
"return_policy_text": r.return_terms,
}
for r in rows
],
# Absolute, https, same host and port as the URL you registered.
"next": (f"https://feeds.example.com/moyasar-ac/catalog?cursor={cursor + PAGE}"
if has_more else None),
})
require "sinatra"
require "json"
PAGE = 500
get "/moyasar-ac/catalog" do
content_type :json
cursor = params.fetch("cursor", 0).to_i
rows = load_products(offset: cursor, limit: PAGE) # your own query
has_more = rows.size == PAGE
{
version: 1,
generated_at: Time.now.utc.iso8601,
items: rows.map do |r|
{
sku: r.sku,
title: r.title,
price_minor: r.price_minor.to_i,
currency: r.currency,
domain_id: "generic_purchase",
# Units you are willing to sell through moyasar-ac, with
# everything already sold through it deducted.
stock_on_hand: r.available_to_moyasar_ac.to_i,
fulfillment_mode: r.physical? ? "pickup" : "digital",
description: r.description,
active: !!r.on_sale?, # a real bool, never "false"
cancellation_policy_text: r.cancellation_terms,
return_policy_text: r.return_terms
}
end,
# Absolute, https, same host and port as the URL you registered.
next: has_more ? "https://feeds.example.com/moyasar-ac/catalog?cursor=#{cursor + PAGE}" : nil
}.to_json
end
Pagination
Set next to the absolute URL of the following page, and null (or
omit it) on the last one. We follow it until it is absent, and every
page is fetched with the same headers and checked against the same
guard as the first.
next must be:
- a string or
null. Any other JSON type —{},[],42,true— ends the traversal as incomplete. It is not treated as "no more pages", because silently truncating your catalog is the one outcome this contract never allows https://, on the same host and the same port as the feed URL you registered. Not merely the same host as the previous page — a chain of same-as-previous hops can't walk away from the origin you registered. A cross-originnextends the traversal as incomplete and is never fetched- not a page we've already fetched in this run. A
nextcycle ends the traversal as incomplete
A query-string cursor, an opaque token, a page number — any of them is
fine, as long as the URL stays on your registered origin.
Caps
| Cap | Value | What happens when it's hit |
|---|---|---|
| Pages per run | 20 | Traversal ends incomplete: feed exceeded 20 pages |
| Items per run | 5,000 | Traversal ends incomplete: feed exceeded 5000 items |
| Bytes per page | 2 MB | That page is cut short and the traversal ends incomplete: feed response was larger than 2MB |
| Seconds per page | 30 | Counted from connect to the last byte of the response. Overrunning it is an ordinary failed poll |
| Wall clock per run | 120 | Checked before each page is fetched, so the run ends incomplete once the next page would start past it: the feed took longer than 120s |
Note that the byte cap is per page, not per run: a 40 MB catalog is
fine, spread over pages that are each under 2 MB.
The 120-second wall clock bounds when we stop starting pages, not when
we stop reading one, so a single source's worst case is closer to 153
seconds — the 120-second budget, plus a 3-second DNS timeout, plus a
final page that is allowed its full 30-second deadline. Size your
endpoint's own timeouts accordingly.
Every cap ends the run as incomplete, and an incomplete run is not a
discarded run: valid items already fetched are applied. What it
suppresses is deactivation — see below.
The polling schedule
Connected, active feeds are polled every 15 minutes. There is no way
to configure the interval in v1, and no way to have moyasar-ac poll only
on demand.
The portal has a Sync now button, which queues a run rather than
running one. It is not immediate: manual runs are serialized against the
scheduled batch, so a click that lands while a batch is in progress
waits for it. It's rate limited to 5 per hour per merchant, and it
is refused for a feed the breaker has disabled — re-enable
it instead.
Only feeds belonging to a portal-approved merchant are polled. A source
whose merchant is suspended stops being polled immediately, mid-batch if
necessary.
What a sync does to your catalog
Each run reads your whole feed, validates every item, writes the valid
ones, and reports the rest. Each item is written in its own transaction,
so one bad row is one rejected row and never a rolled-back run.
| Outcome | When |
|---|---|
succeeded |
Every item was accepted and the traversal completed |
partial |
At least one item was accepted, and either some items were rejected or the traversal ended early |
failed |
No item was accepted — a transport failure, an envelope failure, an empty items array, or every single item rejected |
partial counts as a working feed: your consecutive-failure count
resets, because items really did land.
A product that disappears is deactivated, not deleted
When a complete traversal doesn't mention a SKU that this feed created,
that product is set active: false. It is never deleted. Its
history stays intact, any in-flight reservation against it still
resolves, and it stops appearing in the catalog so no new
agent can buy it. List the SKU again in a later poll and it goes back on
sale with whatever the feed says.
Deactivation also requires that at least one item in the run was
accepted. A run in which nothing landed — an empty items array, or a
feed in which every single row was rejected — is far more likely a
broken endpoint than a merchant who has genuinely stopped selling
everything, so it deactivates nothing.
Only products this feed owns are touched. Hand-entered products, and
products from any other source, are invisible to deactivation — nothing
a feed does can take a product you typed in yourself off sale.
Note also that a SKU your feed named but we rejected is not a
vanished SKU. A typo'd currency on one row will get you a reject, not a
deactivated product.
An incomplete traversal deactivates nothing
If the run ended early for any reason — a cap, a cross-origin next, a
page that timed out, a page that 500'd — nothing is deactivated at
all. A run that died on page 2 of 5 hasn't established that anything
vanished, and treating it as if it had would turn a network blip into a
wiped catalog.
An incomplete traversal also does not count as fresh. Freshness is
measured from the last run that read your feed all the way through, so a
feed that keeps ending early is reported as stale in the portal even
though it's technically been polled every 15 minutes and its
consecutive-failure count is zero. That's deliberate: an incomplete run
refreshed page 1 and nothing else, and it left deactivation suppressed —
so a product you withdrew is still on sale, and you need to see that.
The portal warns once a feed's last complete run is more than an
hour old. Staleness never stops your products selling; it is a signal,
not a cutoff.
Stock is overwritten, not adjusted
Your
stock_on_handmust already account for units sold through
moyasar-ac. Every poll overwrites our count with yours. If your
inventory system does not receive moyasar-ac orders, each poll
re-inflates stock by everything sold since your feed was generated,
and the difference is sold twice.
This is the contract, not a caveat. A sync writes stock_on_hand
absolutely — it never adds, subtracts, or reconciles against what we
previously had. Concretely, if you publish stock_on_hand: 10, we sell
3, and your next feed still says 10 because your warehouse system
never heard about those 3 orders, then we are back to 10 units for sale
against 7 on your shelf.
Two details make this sharper than it looks:
- Units reserved by an in-flight draw are tracked separately and are
never overwritten by a feed. Availability is
stock_on_hand - reserved_count; your feed owns the first number and only the draw path writes the second. If your feed reports stock lower than what's currently reserved, we write it through anyway and availability goes negative, which correctly refuses further sales rather than hiding the discrepancy. - A sale that lands mid-sync loses to the feed, on purpose. If a charge captures against a product in the same instant we're writing it, the write is retried once from fresh state and re-applies your feed's number over the capture. The alternative — letting a stale in-flight write win — would be worse, but it does mean the feed is always the authority, including for a sale that completed seconds ago. If the same row is contended twice in a row, the item is reported as a reject and left for the next poll.
The safe pattern is: deduct moyasar-ac's captured orders in your own
system (via merchant webhooks or the
polling endpoints) before generating the feed,
and publish the resulting number. If you can't do that, publish a
conservative allocation you're prepared to lose — a fixed number of
units set aside for moyasar-ac — rather than your live shelf count.
Rejected items
An item that breaks a rule is rejected individually: it doesn't fail
the run, doesn't affect any other item, and doesn't change whatever we
already have stored for that SKU. Each run records its rejects as
sku / field / key + args, shown in the portal on the Inventory
screen as a sentence in the reader's language.
Up to 50 rejects are stored per run, with a count of any beyond
that, so a badly broken feed is never silently summarized as "50
problems".
Each item is reported with one reason — the first rule it breaks —
so fixing one row can reveal the next problem with it on the following
poll. Values echoed into a reason are truncated at 128 characters.
If the sku itself wasn't a usable string, the reject's sku is
null; there is nothing else we can honestly call the row. In that
case, deactivation is also suppressed for the whole run — we can't
establish which product was listed, and guessing is not acceptable when
the guess takes a live listing off sale.
Item validation errors
Every reason below is stored as a translation key and its values, and
assembled when someone opens the portal — so it appears in the language
of whoever is reading it, not the language the poller happened to run
in. The English wording is reproduced here; %{value} marks where your
own value is echoed back, and echoed values are bidi-isolated so a Latin
SKU or currency code doesn't reorder inside an Arabic sentence.
The field column is not translated in either direction: it names
the JSON key in your own feed, which is the thing you have to go and
edit.
| Field | Message | Cause |
|---|---|---|
item |
This item is not a JSON object. |
An entry in items was a string, number, array or null |
| any required field | This field is required. Your feed left it out, or sent null. |
The key was absent or null. Checked in order: sku, title, price_minor, currency, domain_id, stock_on_hand, fulfillment_mode |
price_minor |
The price must be a whole number of minor units: write 8900 to mean 89.00 — not "8900" as text, and not 89.0. |
A string ("8900"), a float (8900.99), or anything else non-integer |
price_minor |
The price is outside the range we accept (1 to %{max} minor units). Check you are not sending 89.00 as 890000. |
Zero, negative, or beyond a 32-bit integer |
stock_on_hand |
The stock quantity must be a whole number. |
Not a JSON integer |
stock_on_hand |
The stock quantity is outside the range we accept (0 to %{max}). |
Negative, or beyond a 32-bit integer |
sku |
The SKU must be a non-empty string. |
Absent, empty, or not a string |
sku |
The SKU is over the length limit (%{max} characters). |
|
sku |
The SKU must not contain control characters such as a line break or a tab. |
|
sku |
The SKU must not start or end with a space. We do not trim it for you, because trimming could make two rows of your feed point at one product. |
Refused, never trimmed — see Item fields |
title |
The product name must be a non-empty string. |
|
title |
The product name is over the length limit (%{max} characters). |
|
title |
The product name must not contain control characters such as a line break or a tab. |
|
currency |
%{value} is not a currency moyasar-ac supports. |
Not SAR, AED or USD |
currency |
Your account is not set up for %{value}. Remove it from your feed, or contact us to enable it on your account. |
Supported by moyasar-ac, but not enabled on your account |
domain_id |
'domain_id' is not a value we know: %{value}. It must be one of: %{domains}. |
Not one of the six domain ids |
fulfillment_mode |
The fulfillment mode must be one of: %{modes}. |
|
active |
'active' must be the JSON value true or false, not %{value} — that is text, not a boolean. |
"false", 0, "", null — anything that isn't a JSON boolean |
description, cancellation_policy_text, return_policy_text |
This field must be a string. |
Present but not a string (and not null) |
description, cancellation_policy_text, return_policy_text |
This field is over the length limit (%{max} characters). |
Four more rejects come from writing the item rather than validating it:
| Field | Message | Cause |
|---|---|---|
sku |
A product you entered by hand already uses this SKU. Change the SKU in your feed, or edit that product. |
A SKU is your namespace, and a feed never silently overwrites a product you created yourself. Rename one of them, or delete the manual product |
sku |
Another feed you have connected already uses this SKU. |
Same rule, between two feeds |
stock_on_hand |
This product was being purchased while we synced. It will update on the next sync — there is nothing for you to fix. |
The row was contended twice by a live draw. Nothing is wrong; the next poll writes it |
item |
We could not save this item. The fault is on our side, not in your feed — we have logged the details and are looking into it. |
The value passed the schema but the product row refused it. Rare — the schema already checks every field the product validates, so this means a defect on our side, and the specific cause is logged for us rather than shown to you |
Errors that fail the whole run
These are recorded once on the run, not per item, and nothing in your
catalog changes. Like the item rejects above, each is stored as a key
and assembled in the reader's language.
| Message | Cause |
|---|---|
We could not reach your feed. Check that the URL you registered answers over HTTPS on port 443, from the public internet, without a redirect. |
DNS failure, connection refused, TLS failure, a timeout, or a URL that the address guard refused. Deliberately one message for all of them — a distinguishing one would turn the feed poller into a port scanner. The specific reason is logged on our side |
Your feed replied with HTTP %{status}. We read only a 2xx reply, and we never follow redirects. |
Any non-2xx status, including any 3xx — we don't follow redirects, so a redirect is a failed poll |
Your feed replied with HTTP %{status} — it refused our request. Check the bearer token saved above; if your feed is public, remove it. |
401 or 403. Called out separately because the fix is the bearer token on the same screen |
Your feed's response is not valid JSON. |
The body didn't parse |
Your feed is not a JSON object. The top level must be an object holding 'version' and 'items'. |
The top level was an array, a string, or a number |
Your feed does not say which version it is. Add '"version": %{expected}' at the top level. |
version was absent, or null |
Your feed says version %{version}. moyasar-ac reads version %{expected} only. |
version was present but wasn't the number 1 — a stringified "1" reads back as "1", echoed in its literal JSON-ish form |
Your feed has no 'items' array. |
items was missing or wasn't an array |
Your feed's 'generated_at' must be an ISO 8601 string. |
generated_at was present but not a string, or longer than 64 characters |
Your feed's response went over the size limit (%{megabytes}MB). Split it into pages with the 'next' link. |
One page's body exceeded the per-page cap |
Your feed went over the page limit (%{pages}), so we stopped reading. Put more items on each page. |
|
Your feed went over the item limit (%{items}), so we stopped reading. |
|
Reading your feed went over the time limit (%{seconds} seconds), so we stopped. |
|
Your feed's 'next' link points at a different host. It must stay on the address you registered. |
A cross-origin, non-https, different-port, unparseable, or non-string next |
Your feed's 'next' link points back to a page we had already read. |
A next cycle |
Your feed listed no items — the 'items' array is empty. |
A well-formed response with an empty items array |
We could not accept a single item in your feed. The rows below say why. |
Every item was rejected |
The sync could not be completed because of a fault on our side. We have logged the details, and the next scheduled sync will try again. |
An unexpected error on our side. Nothing was left half-applied that we know of, and the run is recorded as failed so the breaker still sees it |
When your feed keeps failing
A feed that produces 10 consecutive failed runs is automatically
disabled, and we stop polling it. What that does and doesn't mean:
- Your products stay on sale. Being unable to reach your endpoint is our failure to observe your catalog, not a statement about it, so nothing is deactivated when the breaker trips. Stock ages instead, and the portal says so prominently.
- Any single non-failed run resets the count to zero. That includes
a
partialrun — items landing means the feed works. - Nothing re-enables it automatically. The portal shows a Re-enable button, which fetches one page of your feed and checks the envelope before clearing the breaker. If that page doesn't come back valid, the feed stays disabled — a click alone won't hand you a green tick over a feed that's still broken. Like every other outbound failure, a refusal here reports one generic message.
- The re-enable check is limited to 5 attempts per minute and reads a single page, so it is not a substitute for fixing the endpoint.
- Only one of these checks runs at a time across the whole platform, so if another merchant's check is in flight yours is declined with a message saying exactly that. It is not a statement about your feed, and nothing about your feed changes — but the attempt still counts against your five per minute, and a single check can hold the slot for up to 30 seconds, so wait about a minute rather than retrying immediately.
Disconnecting and reconnecting is still not the way to clear a
disabled feed: it makes every product that feed created hand-entered,
and the reconnected feed rejects all of them as SKU collisions until you
accept the offer to hand them over. That offer exists so the state is
recoverable — not so it becomes a routine. Use Re-enable.
Intent Mandates
An intent mandate is a capped, signed spending authorization scoped to
one user. It starts awaiting_approval and does nothing until the user
approves it through the hosted approval flow.
Requires a bearer token (see Authentication) with the
mandates:write scope.
Create a mandate
curl -X POST http://localhost:3000/v1/intent-mandates \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"user_identifier": "traveler@example.com",
"currency": "SAR",
"env": "sandbox",
"total_cap_minor": 900000,
"domain_id": "flight_booking",
"not_valid_after": "2026-08-01T00:00:00Z",
"intent_payload": {
"description": "Business-class flight to Dubai",
"flight": { "route": "RUH-DXB", "cabin": "business" }
},
"mandate_kind": "one_shot_multi_draw"
}'
import httpx
resp = httpx.post(
"http://localhost:3000/v1/intent-mandates",
headers={"Authorization": f"Bearer {access_token}"},
json={
"user_identifier": "traveler@example.com",
"currency": "SAR",
"env": "sandbox",
"total_cap_minor": 900000,
"domain_id": "flight_booking",
"not_valid_after": "2026-08-01T00:00:00Z",
"intent_payload": {
"description": "Business-class flight to Dubai",
"flight": {"route": "RUH-DXB", "cabin": "business"},
},
"mandate_kind": "one_shot_multi_draw",
},
)
print(resp.json())
require "net/http"
require "json"
uri = URI("http://localhost:3000/v1/intent-mandates")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
"Authorization" => "Bearer #{access_token}")
req.body = {
user_identifier: "traveler@example.com",
currency: "SAR",
env: "sandbox",
total_cap_minor: 900000,
domain_id: "flight_booking",
not_valid_after: "2026-08-01T00:00:00Z",
intent_payload: {
description: "Business-class flight to Dubai",
flight: { route: "RUH-DXB", cabin: "business" }
},
mandate_kind: "one_shot_multi_draw"
}.to_json
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Request fields
| Field | Type | Required | Description |
|---|---|---|---|
user_identifier |
string | yes | Email or E.164 phone (e.g. +9665...). Creates the user if they don't exist yet. |
currency |
string | yes, unless product_id is present |
One of SAR, AED, USD. Derived from the listing on a product-driven mandate. |
env |
string | yes | sandbox or live. |
total_cap_minor |
integer | yes | Maximum total spend, in minor currency units (e.g. 900000 = 9,000.00). |
per_draw_cap_minor |
integer | no | Maximum for any single draw. Omit for no per-draw limit. |
not_valid_before |
ISO 8601 datetime | no | Mandate isn't usable before this time. |
not_valid_after |
ISO 8601 datetime | no | Mandate expires after this time. |
domain_id |
string | yes, unless product_id is present |
flight_booking, generic_purchase, hotel_booking, car_rental, activity_booking, or retail_purchase — selects which intent_payload schema applies (below). Derived from the listing on a product-driven mandate. |
intent_payload |
object | yes | Validated against the schema for domain_id. |
mandate_kind |
string | no (default one_shot_multi_draw) |
one_shot_multi_draw or recurring. |
recurrence |
object | required if mandate_kind: recurring |
See below. |
product_id |
UUID | no | ID of a listing from the merchant catalog. When present, this becomes a product-driven mandate — see below. |
product_quantity |
integer | no (default 1) |
Units of product_id being purchased. Ignored if product_id is omitted. |
Product-driven mandates
Passing product_id (from GET /v1/catalog) changes how a
few fields are handled, because the listing itself is the source of
truth for a catalog purchase — an agent's own claims about it aren't
trusted:
domain_idandcurrencyare derived from the product and override whatever you sent for those fields — an agent-supplied value for either is treated as a stale or adversarial claim about a listing it doesn't own, not merely cross-checked against it. Because both are derived, you may also simply omit them on a product-driven request;currencyis only validated after the listing's own value has been resolved, so omitting it is not an error here even though it's required for every other mandate. This applies to an outright invalidcurrencytoo — sending"GBP"alongside aproduct_idyields the listing's currency rather than a422, since the field is overridden either way.- If the resolved
domain_id's schema declares aquantityfield (today onlyretail_purchasedoes),intent_payload.quantityis forced to matchproduct_quantity, overriding whatever you sent. The two are otherwise independent —product_quantitydrives the price floor and the stock reservation, whileintent_payload.quantityis what the hosted approval page shows the user verbatim before they sign. Reconciling them prevents a mandate where the user approves one number of units and the system charges and ships for another. total_cap_minormust be at leastproduct.price_minor * product_quantity. This is a floor, not an equality check — a larger cap to cover tax, fees, or a tip is accepted — but a cap below that floor is rejected up front, since it would let the mandate the user approves diverge from what the merchant could legitimately draw.- The product must have enough available stock
(
stock_on_hand - reserved_count) forproduct_quantity, or the request is rejected immediately — so a listing whose every unit is already reserved by an in-flight draw will refuse a new mandate. This is a fast-fail check only: it does not itself reserve stock, so a mandate created here can still be refused withproduct_out_of_stockat draw time. - If the product has
cancellation_policy_textand/orreturn_policy_textset, that text is copied verbatim intointent_payloadbefore schema validation — but only for whichever of those two fields the resolveddomain_id's schema actually declares (e.g.retail_purchasedeclares onlyreturn_policy_text;hotel_booking/car_rental/activity_bookingdeclare onlycancellation_policy_text). This way it's covered by the same signature the user's passkey later applies to the whole mandate, without ever tripping the schema'sadditionalProperties: false. - If the
intent_payloadyou sent does not already validate against the resolveddomain_id's schema (e.g. it's a plain{"description": "..."}, the shape a caller with no domain-specific fields of its own — such as the MCPbrowse_catalogpicker — actually has to send),retail_purchasederives one for you:variantfrom the product's owntitle(truncated to the schema's own 120-character limit — thetitlecolumn itself has no length cap),quantityfromproduct_quantity. This is only ever a fallback — a caller that already sent a complete, validintent_payload(including a realreturn_policy_ackit obtained from the user) has it respected exactly as sent, never overwritten.
return_policy_ackis never derived this way, on any domain — it is an acknowledgment, not a fact about the product, and this API will not assert the user agreed to a policy it never showed them. A product-drivenretail_purchasemandate whose caller supplies nointent_payloadfields of its own will therefore still422onreturn_policy_ackspecifically — regardless of how long the underlying product's title is — never onvariant/quantity/a disalloweddescription. Narrower than before, but still a real rejection until a caller (or a future approval-page flow) supplies a genuine one.hotel_booking/car_rental/activity_bookinghave no such fallback at all today — their required fields (booking dates, locations, guest counts) aren't facts a catalog listing carries in the first place, so a product-driven mandate on those domains still requires a caller-suppliedintent_payloadin full.
intent_payload for domain_id: "generic_purchase"
| Field | Type | Required | Description |
|---|---|---|---|
description |
string | yes | 1–280 characters. |
reference_links |
array of strings (URIs) | no | Up to 10 supporting links. |
intent_payload for domain_id: "flight_booking"
| Field | Type | Required | Description |
|---|---|---|---|
description |
string | yes | 1–280 characters. |
flight.route |
string | no | e.g. "RUH-DXB". |
flight.depart_after |
string | no | YYYY-MM-DD. |
flight.return_after |
string | no | YYYY-MM-DD. |
flight.cabin |
string | no | e.g. "business". |
flight.airline |
string | no | e.g. "Emirates" (defaults to Saudia if omitted). |
intent_payload for domain_id: "hotel_booking"
| Field | Type | Required | Description |
|---|---|---|---|
property_name |
string | yes | 1–120 characters. |
check_in |
string | yes | YYYY-MM-DD. |
check_out |
string | yes | YYYY-MM-DD. |
guest_count |
integer | yes | 1–20. |
room_type |
string | no | Up to 64 characters. |
cancellation_policy_ack |
boolean | yes | Must be exactly true — the user's acknowledgment of the cancellation policy. |
cancellation_policy_text |
string | no | Up to 2000 characters. Filled in automatically from the listing on a product-driven mandate. |
intent_payload for domain_id: "car_rental"
| Field | Type | Required | Description |
|---|---|---|---|
pickup_location |
string | yes | 1–120 characters. |
return_location |
string | yes | 1–120 characters. |
pickup_date |
string | yes | YYYY-MM-DD. |
return_date |
string | yes | YYYY-MM-DD. |
driver_age_confirmed |
boolean | yes | Must be exactly true. |
cancellation_policy_ack |
boolean | yes | Must be exactly true. |
cancellation_policy_text |
string | no | Up to 2000 characters. Filled in automatically from the listing on a product-driven mandate. |
intent_payload for domain_id: "activity_booking"
| Field | Type | Required | Description |
|---|---|---|---|
activity_name |
string | yes | 1–120 characters. |
event_date |
string | yes | YYYY-MM-DD. |
participant_count |
integer | yes | 1–50. |
cancellation_policy_ack |
boolean | yes | Must be exactly true. |
cancellation_policy_text |
string | no | Up to 2000 characters. Filled in automatically from the listing on a product-driven mandate. |
intent_payload for domain_id: "retail_purchase"
| Field | Type | Required | Description |
|---|---|---|---|
variant |
string | yes | 1–120 characters, e.g. "Size 42". |
quantity |
integer | yes | 1–100. On a product-driven mandate this is overwritten to match product_quantity — see above. |
return_policy_ack |
boolean | yes | Must be exactly true. |
return_policy_text |
string | no | Up to 2000 characters. Filled in automatically from the listing on a product-driven mandate. |
recurrence (only when mandate_kind: "recurring")
| Field | Type | Required | Description |
|---|---|---|---|
cadence |
string | yes | monthly, weekly, metered, or unscheduled_topup. |
charge_trigger |
string | yes | merchant_subscription_tick, usage_threshold, or balance_low. |
max_per_period_minor |
integer | required for monthly/weekly |
Per-period spending ceiling; must not exceed total_cap_minor. |
expected_amount_minor |
integer | no | Informational expected charge size. |
Mandate creation response
{
"intent_mandate_id": "7f8639ee-7039-412e-ac87-503b8e9986f1",
"approval_url": "http://localhost:3000/m/Z3su5K-CTVmk9Uhv5ZvO0CYyNByXpGnC34QeEVAqtNs",
"draw_key": "wHyJGRAjTGmNDxDosja5Cc63sqgJK3qwG2fAa4X16tU"
}
Share approval_url with the user — nothing can be charged until they
complete the hosted approval flow there.
draw_keyis a merchant secret, shown exactly once. It's the
credential a merchant uses to sign Draws requests against
this mandate. If your agent is acting on behalf of a merchant, relay
this value to them out-of-band — never log it or forward it into an
LLM's context (the MCP tool layer deliberately omits this field from
what it returns to a model — see MCP Tools).
Intent mandate errors
| Status | error |
Meaning |
|---|---|---|
400 |
missing_field |
A required field (e.g. user_identifier) was omitted entirely |
401 |
unauthorized |
Missing/invalid bearer token |
403 |
insufficient_scope |
Token lacks mandates:write |
429 |
rate_limited |
More than 10 mandate creations for the same user_identifier, or more than 60 from this agent in total, in the current minute |
422 |
(message varies, e.g. "Unsupported currency: ...") |
Invalid currency, env, mandate_kind, cadence, charge_trigger, or recurrence math |
422 |
intent_payload_invalid (+ details) |
intent_payload failed the domain's JSON schema |
422 |
"user is not cleared for live mode" |
env: "live" was requested, but the resolved user hasn't been explicitly cleared for live mode |
422 |
"live mode kill switch is enabled" |
env: "live" was requested, but the platform-wide live-mode kill switch is on |
422 |
"daily live mandate cap exceeded" |
env: "live" was requested, but this user has already reached their per-day live-mandate creation cap |
422 |
"Unknown product_id: ..." |
product_id doesn't match any catalog listing |
422 |
"product_quantity must be at least 1" |
product_quantity was 0 or negative |
422 |
"Product does not have enough stock" |
product_id's listing is inactive, or its available stock (stock_on_hand - reserved_count) is below product_quantity |
422 |
"total_cap_minor must be at least ... for Nx this product" |
total_cap_minor is below product.price_minor * product_quantity |
env: "sandbox" requests are never subject to the three live-mode checks
above.
Check mandate status
curl http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/status \
-H "Authorization: Bearer $ACCESS_TOKEN"
resp = httpx.get(
"http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/status",
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.json())
uri = URI("http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/status")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{access_token}")
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Mandate status response
{
"intent_mandate_id": "7f8639ee-7039-412e-ac87-503b8e9986f1",
"status": "approved",
"approved": true,
"currency": "SAR",
"total_cap_minor": 900000,
"total_drawn_minor": 450000,
"no_money_moved": false,
"last_booking_status": "drawn",
"payment_status": "captured",
"latest_draw_charged_minor": 450000
}
status is one of draft, awaiting_approval, approved, revoked,
expired, exhausted, or suspended. Draws are refused while a mandate is
suspended — moyasar-ac found this mandate's recorded outcomes disagreeing
with the payment processor and paused it pending operator reconciliation.
total_drawn_minor is always present, including a genuine 0 — it's the
same cumulative captured-total field the merchant mandate
view already exposes, here alongside
total_cap_minor for the agent that created the mandate. This is the
figure to trust as "the amount charged": a mandate can draw more than
once (mandate_kind: "recurring", or a multi-draw one_shot_multi_draw),
and summing only the latest draw understates what has actually left the
account.
no_money_moved is the only field you may use to tell a user that
nothing was charged. It is true when this mandate has no payment record
of any kind, false otherwise — the same predicate the hosted receipt page
uses for its own version of that claim, so the two can never disagree.
last_booking_status and payment_status are null until a booking attempt
actually exists — a flight_booking mandate, or a retail_purchase/
generic_purchase mandate backed by a product_id, once
auto-draw has reserved a draw. last_booking_status
is complete_booking's own outcome word for the most
recent attempt (drawn, already_booked, cap_exhausted,
mandate_not_approved, not_ready, or a cart_signature_* refusal). It is
rewritten on every attempt — including the one the hosted receipt page
makes each time it is loaded, which typically turns drawn into
already_booked — but it never reflects the outcome of the async charge:
it describes whether a draw was placed, not whether it was paid.
payment_status is the linked draw's own status (pending, captured, or
failed) and is the field that actually distinguishes "still charging" from
"done" from "failed"; last_booking_status alone cannot, since it can still
read drawn after the charge has failed.
latest_draw_charged_minor is null until payment_status is captured —
once set it is the most recent draw's own amount (in the currency shown
above), never a placeholder and never 0 standing in for "nothing was
charged yet". It is deliberately not named charged_minor: on a
mandate with more than one captured draw it would read as the total while
actually reporting only the latest one (e.g. two SAR 100/200 captures would
report latest_draw_charged_minor: 20000 while SAR 300 had left the
account) — use total_drawn_minor for anything that needs "how much has
this mandate charged in total".
Only the agent that created a mandate can query its status — a mismatched
agent or unknown ID returns 404 {"error": "not_found"}.
Preview a mandate's cart
For a mandate on a completable domain — flight_booking, or
retail_purchase/generic_purchase backed by a product_id — auto-draw won't fire until the agent has signed the cart's
exact terms (see Submit a cart signature below)
— this endpoint returns that cart, unsigned, so the agent has something
concrete to sign. For a flight_booking mandate the fare is computed from
the mandate's intent_payload.flight fields and clamped to whatever remains
under total_cap_minor; for a catalog mandate the total is price_minor × from the linked listing, and a price that no longer fits
product_quantity
the remaining cap is refused outright rather than clamped (see
price_changed below). Either way, the agent can't
use this endpoint to inflate or invent a price, only to see (and later
attest to) the terms moyasar-ac already decided. Only reachable for
mandates on a completable domain, owned by the requesting agent — any other
domain (e.g. hotel_booking) returns domain_not_supported.
curl http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/cart-preview \
-H "Authorization: Bearer $ACCESS_TOKEN"
resp = httpx.get(
"http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/cart-preview",
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.json())
uri = URI("http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/cart-preview")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{access_token}")
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Cart preview response
{
"intent_mandate_id": "7f8639ee-7039-412e-ac87-503b8e9986f1",
"currency": "SAR",
"line_items": [
{ "name": "Saudia RUH–DXB business", "amount_minor": 420000 }
],
"total_minor": 420000
}
Cart preview errors
| Status | error |
Meaning |
|---|---|---|
401 |
unauthorized |
Missing/invalid bearer token |
403 |
insufficient_scope |
Token lacks mandates:write |
404 |
not_found |
Mandate doesn't exist, or belongs to a different agent |
422 |
domain_not_supported |
Mandate's domain isn't completable — not flight_booking, and not a retail_purchase/generic_purchase mandate backed by a product_id |
422 |
price_changed |
Catalog mandates only. The product's price_minor × product_quantity no longer fits the mandate's remaining authority — the overall cap headroom, additionally bounded by per_draw_cap_minor when one is set — or its currency no longer matches the mandate's. Reachable either because the merchant re-listed the SKU (feed syncs poll every 15 minutes) after the mandate was approved, or because the mandate's own per_draw_cap_minor (fixed at creation, never changes) was narrower than the product's price from the start. Not terminal — see the price_changed row below; a later price correction (the only thing that ever changes here — per_draw_cap_minor doesn't) can make the next preview succeed. |
Submit a cart signature
Sign the exact cart returned by cart-preview
locally with your agent's own Ed25519 private key (the half moyasar-ac
never sees — see Cart signatures for the canonicalize/
sign/envelope steps, same rule here), then submit both the cart and the
signature envelope. moyasar-ac recomputes the cart itself from the
mandate rather than trusting what you send — your submitted cart must
byte-match that recomputation (via
RFC 8785 JCS) and your signature
must verify against it, or the request is rejected. Nothing is stored
until both checks pass. The stored signature is only consumed later, when
auto-draw actually reserves the charge — this
call doesn't draw anything by itself, and can be re-submitted (e.g. after
a fare changes) any time before that happens.
curl -X POST http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/cart-signature \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"cart": {
"intent_mandate_id": "7f8639ee-7039-412e-ac87-503b8e9986f1",
"currency": "SAR",
"line_items": [ { "name": "Saudia RUH–DXB business", "amount_minor": 420000 } ],
"total_minor": 420000
},
"agent_signature": { "alg": "Ed25519", "kid": "a1b2c3d4e5f6a7b8", "value": "..." }
}'
resp = httpx.post(
"http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/cart-signature",
headers={"Authorization": f"Bearer {access_token}"},
json={
"cart": cart, # exactly what cart-preview returned
"agent_signature": {"alg": "Ed25519", "kid": signing_kid, "value": signature_b64url},
},
)
print(resp.json())
uri = URI("http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/cart-signature")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
"Authorization" => "Bearer #{access_token}")
req.body = {
cart: cart, # exactly what cart-preview returned
agent_signature: { alg: "Ed25519", kid: signing_kid, value: signature_b64url }
}.to_json
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Cart signature request fields
| Field | Type | Required | Description |
|---|---|---|---|
cart |
object | yes | Must byte-match (via JCS) what GET .../cart-preview returns for this mandate right now. |
agent_signature |
object | yes | {alg, kid, value} envelope — kid must equal the agent's own registered signing_kid; value is base64url(Ed25519 signature), no padding. |
Cart signature response
{ "status": "pending" }
Cart signature errors
| Status | error |
Meaning |
|---|---|---|
400 |
malformed_request |
cart or agent_signature missing entirely |
401 |
unauthorized |
Missing/invalid bearer token |
403 |
insufficient_scope |
Token lacks mandates:write |
404 |
not_found |
Mandate doesn't exist, or belongs to a different agent |
409 |
already_drawn |
This mandate already has a finalized cart (already drawn) — signing is frozen |
422 |
domain_not_supported |
Mandate's domain isn't completable — not flight_booking, and not a retail_purchase/generic_purchase mandate backed by a product_id |
422 |
price_changed |
Catalog mandates only, same meaning as cart-preview's row above (including the per_draw_cap_minor case) — the price can drift between an agent's cart-preview call and its cart-signature submission. Not terminal. |
422 |
cart_mismatch |
Submitted cart doesn't byte-match what moyasar-ac independently computes for this mandate right now |
422 |
signature_invalid |
agent_signature.kid doesn't match the agent's registered signing_kid, or the signature doesn't verify against the (server-recomputed) canonical cart bytes |
Complete a booking
Asks moyasar-ac to complete a mandate on a completable domain
(flight_booking, or retail_purchase/generic_purchase backed by a
product_id) now, instead of waiting for the background sweeper that
picks up approved, tokenized bookings on its own — the sweeper scans every
completable domain, not just flights. Call it once
status reports approved — by then the user has
approved the mandate and added a card, which is everything completion needs.
POST /v1/intent-mandates/:id/complete-booking
This endpoint grants no authority the sweeper doesn't already have; it only
changes when the same work happens. It is idempotent — the draw is
serialized on the mandate's draw key, so calling it repeatedly (or racing it
against the sweeper, or against the user loading the receipt page) can
produce at most one booking. The charge itself is reserved through the same
draw validation path a merchant would use, so every cap, expiry,
and cart-signature check still applies, and a refusal is returned to you
rather than retried.
curl -X POST http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/complete-booking \
-H "Authorization: Bearer $ACCESS_TOKEN"
resp = httpx.post(
"http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/complete-booking",
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.json())
uri = URI("http://localhost:3000/v1/intent-mandates/7f8639ee-7039-412e-ac87-503b8e9986f1/complete-booking")
req = Net::HTTP::Post.new(uri, "Authorization" => "Bearer #{access_token}")
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Complete booking response
{ "status": "drawn" }
A 200 is returned for every outcome, including a refusal — the outcome is
in status, not the HTTP code. Only transport/authorization problems use a
non-200 code (see the error table below).
status |
Meaning | What to do |
|---|---|---|
drawn |
The booking was reserved and the charge enqueued. | Tell the user it's booked. The capture settles asynchronously — merchants can follow it via Merchant webhooks. |
already_booked |
This mandate had already drawn. | Nothing — this is the safe idempotent answer, not an error. |
not_ready |
The mandate isn't approved yet, or no active card is linked. |
Wait and re-check status. Not terminal — the sweeper will complete it once it's ready. |
ignored |
The mandate's domain isn't completable: not flight_booking, and not a retail_purchase/generic_purchase mandate created from a catalog product_id. |
Nothing to complete. |
cap_exhausted |
No authorized amount remains on the mandate. | Terminal. Report it; a retry cannot succeed. |
mandate_not_approved |
The mandate is revoked, expired, or exhausted — the user took it away, or it aged out. |
Terminal. Tell the user; no amount of waiting brings it back. Create a new mandate if they still want the booking. |
price_changed |
Catalog mandates only (retail_purchase/generic_purchase backed by a product_id). The product's price × quantity exceeds the mandate's remaining authority — the overall cap headroom, additionally bounded by per_draw_cap_minor when one is set — or the product's currency no longer matches the mandate's. Reachable either because the merchant re-listed the SKU (feed syncs poll every 15 minutes) after the mandate was approved, or because the mandate's own per_draw_cap_minor (fixed at creation, never changes) was narrower than the product's price from the start. Never clamped or coerced; a divergent cart is refused outright. |
Not terminal. total_cap_minor and per_draw_cap_minor never change on their own, but the product can — a later sync that drops the price (and/or realigns the currency) back within whichever authority applies lets a retry succeed. Re-check status and call again. |
cart_signature_malformed |
No cart signature was ever submitted for this mandate. | Not terminal. Submit a signature, then call again. |
cart_signature_invalid |
The stored signature no longer verifies against the cart moyasar-ac computes today (e.g. the fare got clamped differently by an intervening draw). | Not terminal. Re-run cart-preview and cart-signature, then call again. |
| any other value | A draw rejection reason, passed straight through (e.g. cap_exceeded, cart_terms_mismatch, period_cap_exceeded). |
Not terminal. Report the reason to the user; fix the cause and call again if you can. |
Only cap_exhausted and mandate_not_approved are treated as final. Every
other refusal — including price_changed — is one the background sweeper
will keep retrying for as long as its 24-hour window lasts, so the user's
booking page shows the reason without claiming the booking is over — see
Auto-draw and receipt.
Complete booking errors
| Status | error |
Meaning |
|---|---|---|
401 |
unauthorized |
Missing/invalid bearer token |
403 |
insufficient_scope |
Token lacks mandates:write |
404 |
not_found |
Mandate doesn't exist, or belongs to a different agent |
Merchant view of a mandate
A separate, merchant-authenticated endpoint (same credential scheme as
Draws) returns a redacted view — the financial envelope and
intent, never the user's identity:
GET /v1/intent-mandates/:id
Merchant mandate view response
{
"id": "7f8639ee-7039-412e-ac87-503b8e9986f1",
"status": "approved",
"currency": "SAR",
"env": "sandbox",
"total_cap_minor": 900000,
"per_draw_cap_minor": null,
"total_drawn_minor": 420000,
"pending_amount_minor": 0,
"not_valid_before": null,
"not_valid_after": "2026-08-01T00:00:00Z",
"domain_id": "flight_booking",
"intent_payload": { "description": "Business-class flight to Dubai", "flight": { "route": "RUH-DXB", "cabin": "business" } }
}
Draws
A draw is a merchant-initiated charge against an approved mandate.
Unlike every other endpoint on this page, draws don't use a bearer
token — they use a merchant API key plus an HMAC-SHA256 request
signature, so a compromised network intermediary can't replay or tamper
with a charge request.
Getting merchant credentials: sign up at
/merchant/applications/new
in the merchant portal. Yourmerchant_idand initial API key are
minted immediately at signup — the key is shown once, on that screen,
and never again. The key authenticates straight away, but draws are
refused withmerchant_not_portal_approveduntil an operator approves
your application. See
Rotating your key below for how to replace it
later without an integration outage.
Authentication scheme
Every request needs:
Authorization: Merchant <your-api-key>X-Merchant-Id: <your-merchant-id>X-Mandate-Id: <the intent_mandate_id you're drawing against>X-Nonce: <a fresh random value per request>X-Timestamp: <current time, ISO 8601, must be within 5 minutes>X-Idempotency-Key: <a key you control — retrying with the same key returns the original response instead of double-charging>X-Signature: <computed below>
Computing the signature
- Build the canonical string — nine fields joined by
\n, in this exact order:
METHOD
HOST
PATH
base64url(SHA256(raw request body bytes))
mandate_id
merchant_id
nonce
timestamp
idempotency_key
X-Signature=base64url(HMAC-SHA256(draw_key_secret, canonical_string))
draw_key_secret is the draw_key value returned once when the
mandate was created.
base64url here means standard base64 with +→-, /→_, and
= padding stripped (RFC 4648 §5).
Rotating your key
Replace your API key at any time from the merchant portal, at
/merchant/api-keys — there is no API for this in v1. Rotating:
- Mints a new key immediately and shows it to you once — copy it before you navigate away, the same as a webhook signing secret.
- Requires you to pick a retirement date for the key being replaced, strictly in the future and no more than 90 days out. This is mandatory: an old key with no deadline is a permanent security hole, so moyasar-ac refuses to rotate without one, and refuses a date far enough out to amount to the same thing. Within that 90-day ceiling, you pick the date, not moyasar-ac — we don't know your deploy schedule, and a platform-chosen window would either cut you off mid-rollout or leave a leaked key alive too long.
- Both the new key and the outgoing one authenticate every request until the outgoing one's retirement date passes or you disable it — check its last used time on the portal before that date to confirm your integration has actually switched over.
You can also disable the outgoing key immediately, before its
scheduled retirement, from the same screen — useful if you rotated
because a key leaked and can't wait out the grace period.
You can hold at most two live keys at a time. While you already
hold two, rotating again is refused until one of them stops being live
— either its retirement date passes or you disable it. This keeps the
number of keys the platform authenticates against per request bounded,
so it isn't something a rotation loop can inflate.
The cap counts live keys, not outgoing ones, so the leaked-key path
stays open: if you rotate and then immediately disable the new
current key because it leaked, you are down to one live key and can
rotate again straight away.
Key states
Every key you hold is in exactly one of these states, shown on
/merchant/api-keys alongside the time it was last used to
authenticate a request:
| State | expires_at / disabled_at |
Authenticates? |
|---|---|---|
| Current | no retirement date, not disabled | Yes — indefinitely |
| Outgoing | retirement date in the future | Yes — until that date passes |
| Expired | retirement date has passed | No |
| Disabled | disabled from the portal | No — from the moment you disable it, whatever its retirement date said |
Disabled wins over expired: a key you disabled stays reported as
disabled even after its retirement date goes by.
When a key stops working
Expiry is evaluated on every authenticated request, against the
clock at that moment — it is not applied by a nightly job or any other
background sweep. The practical consequence is that a retirement date
is exact: the last request before it passes succeeds, and the next one
does not. Disabling a key behaves the same way, taking effect on the
very next request rather than at some later sweep.
A request presenting an expired or disabled key — or simply the wrong
key — is refused identically:
Response
401 Unauthorized
{
"error": "unauthorized"
}
There is no distinct error for "your key expired": moyasar-ac does not
tell an unauthenticated caller why a key was rejected, so treat
401 unauthorized on a previously working integration as "this key is
no longer live — deploy the new one".
Watch the "last used" column, not the calendar. It is stamped every
time a key successfully authenticates a request, so it is the direct
answer to the only question that matters before a retirement date
arrives: has anything still got the old key? An outgoing key whose last
used time keeps advancing is an outage scheduled for that date.
HOSTis a configured value, not the literal address you connect
to: the server signs againstMERCHANT_API_HOST(Rails config
config.x.merchant_api_host), which may differ from the host/port
you're actually connecting to. The samples below use
HOST="localhost:3000"because that matches a server explicitly
configured withMERCHANT_API_HOST=localhost:3000— if your server
uses a different value (the default ismoyasar-ac.local), use that
value instead or you'll get401 signature_invalid.
Create a draw
#!/usr/bin/env bash
HOST="localhost:3000"
PATH_="/v1/draws"
MANDATE_ID="7f8639ee-7039-412e-ac87-503b8e9986f1"
MERCHANT_ID="your-merchant-id"
DRAW_KEY_SECRET="the-draw_key-from-mandate-creation"
# cart's agent_signature/merchant_signature are computed as described in
# "Cart signatures" below -- omitted here for brevity, shown as an example shape.
BODY='{"amount_minor":420000,"currency":"SAR","cart":{"line_items":[{"sku":"seat-2a","amount_minor":420000}],"total_minor":420000,"agent_signature":{"alg":"Ed25519","kid":"a1b2c3d4e5f6a7b8","value":"..."},"merchant_signature":{"alg":"Ed25519","kid":"1a2b3c4d5e6f7a8b","value":"..."}}}'
NONCE=$(openssl rand -hex 16)
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
IDEMPOTENCY_KEY=$(uuidgen)
b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
BODY_SHA=$(printf '%s' "$BODY" | openssl dgst -sha256 -binary | b64url)
CANONICAL=$(printf 'POST\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \
"$HOST" "$PATH_" "$BODY_SHA" "$MANDATE_ID" "$MERCHANT_ID" "$NONCE" "$TIMESTAMP" "$IDEMPOTENCY_KEY")
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$DRAW_KEY_SECRET" -binary | b64url)
curl -X POST "http://$HOST$PATH_" \
-H "Content-Type: application/json" \
-H "Authorization: Merchant your-merchant-api-key" \
-H "X-Merchant-Id: $MERCHANT_ID" \
-H "X-Mandate-Id: $MANDATE_ID" \
-H "X-Nonce: $NONCE" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Idempotency-Key: $IDEMPOTENCY_KEY" \
-H "X-Signature: $SIGNATURE" \
-d "$BODY"
import base64
import hashlib
import hmac
import httpx
import uuid
from datetime import datetime, timezone
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
host = "localhost:3000"
path = "/v1/draws"
mandate_id = "7f8639ee-7039-412e-ac87-503b8e9986f1"
merchant_id = "your-merchant-id"
draw_key_secret = "the-draw_key-from-mandate-creation"
# cart's agent_signature/merchant_signature are computed as described in
# "Cart signatures" below -- omitted here for brevity, shown as an example shape.
body = (
b'{"amount_minor":420000,"currency":"SAR","cart":{"line_items":'
b'[{"sku":"seat-2a","amount_minor":420000}],"total_minor":420000,'
b'"agent_signature":{"alg":"Ed25519","kid":"a1b2c3d4e5f6a7b8","value":"..."},'
b'"merchant_signature":{"alg":"Ed25519","kid":"1a2b3c4d5e6f7a8b","value":"..."}}}'
)
nonce = uuid.uuid4().hex
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
idempotency_key = str(uuid.uuid4())
body_sha = b64url(hashlib.sha256(body).digest())
canonical = "\n".join([
"POST", host, path, body_sha, mandate_id, merchant_id, nonce, timestamp, idempotency_key,
])
signature = b64url(hmac.new(draw_key_secret.encode(), canonical.encode(), hashlib.sha256).digest())
resp = httpx.post(
f"http://{host}{path}",
content=body,
headers={
"Content-Type": "application/json",
"Authorization": "Merchant your-merchant-api-key",
"X-Merchant-Id": merchant_id,
"X-Mandate-Id": mandate_id,
"X-Nonce": nonce,
"X-Timestamp": timestamp,
"X-Idempotency-Key": idempotency_key,
"X-Signature": signature,
},
)
print(resp.status_code, resp.json())
require "net/http"
require "json"
require "openssl"
require "base64"
require "securerandom"
require "time"
def b64url(bytes)
Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end
host = "localhost:3000"
path = "/v1/draws"
mandate_id = "7f8639ee-7039-412e-ac87-503b8e9986f1"
merchant_id = "your-merchant-id"
draw_key_secret = "the-draw_key-from-mandate-creation"
# cart's agent_signature/merchant_signature are computed as described in
# "Cart signatures" below -- omitted here for brevity, shown as an example shape.
body = {
amount_minor: 420_000, currency: "SAR",
cart: {
line_items: [ { sku: "seat-2a", amount_minor: 420_000 } ], total_minor: 420_000,
agent_signature: { alg: "Ed25519", kid: "a1b2c3d4e5f6a7b8", value: "..." },
merchant_signature: { alg: "Ed25519", kid: "1a2b3c4d5e6f7a8b", value: "..." }
}
}.to_json
nonce = SecureRandom.hex(16)
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
idempotency_key = SecureRandom.uuid
body_sha = b64url(OpenSSL::Digest::SHA256.digest(body))
canonical = ["POST", host, path, body_sha, mandate_id, merchant_id, nonce, timestamp, idempotency_key].join("\n")
signature = b64url(OpenSSL::HMAC.digest("SHA256", draw_key_secret, canonical))
uri = URI("http://#{host}#{path}")
req = Net::HTTP::Post.new(uri,
"Content-Type" => "application/json",
"Authorization" => "Merchant your-merchant-api-key",
"X-Merchant-Id" => merchant_id,
"X-Mandate-Id" => mandate_id,
"X-Nonce" => nonce,
"X-Timestamp" => timestamp,
"X-Idempotency-Key" => idempotency_key,
"X-Signature" => signature)
req.body = body
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts res.body
Request body
| Field | Type | Required | Description |
|---|---|---|---|
amount_minor |
integer | yes | Charge amount in minor units. Must be > 0. |
currency |
string | yes | Must match the mandate's currency exactly. |
cart |
object | yes | line_items, total_minor, plus the agent_signature/merchant_signature envelopes described below. A missing or cryptographically invalid cart is rejected — see Cart signatures. |
Cart signatures
The cart object must be independently signed by both the agent and
the merchant, each with the Ed25519 keypair registered for their
signing_kid (provisioned out-of-band alongside your API key — there's
no self-service key-registration endpoint in this PoC yet). The signature
makes the cart's contents tamper-evident (any edit after signing
invalidates it), and the cart's total_minor is additionally required to
equal the request's top-level amount_minor (and, when the cart declares
them, its currency/intent_mandate_id must match the request/mandate
too) — so a validly-signed cart for one set of terms can't be submitted
against a different charge. A tampered or mismatched cart is rejected
before it can consume a nonce-replay slot.
- Canonicalize the cart excluding the
agent_signatureandmerchant_signaturekeys themselves, using RFC 8785 JSON Canonicalization Scheme (JCS). - Ed25519-sign the canonical bytes with the signer's private key.
- Attach the result as an envelope:
{
"alg": "Ed25519",
"kid": "<the signer's registered signing_kid>",
"value": "<base64url(signature), no padding>"
}
Each envelope's kid must exactly equal that party's own registered
signing_kid — the public key used to verify is always looked up from
the agent/merchant record itself, never from anything inside the cart
payload, which closes a kid-confusion attack a naive "verify against
whatever kid the payload claims" implementation would open.
{
"line_items": [ { "sku": "seat-2a", "amount_minor": 420000 } ],
"total_minor": 420000,
"agent_signature": { "alg": "Ed25519", "kid": "a1b2c3d4e5f6a7b8", "value": "..." },
"merchant_signature": { "alg": "Ed25519", "kid": "1a2b3c4d5e6f7a8b", "value": "..." }
}
Response 202 Accepted
{
"payment_mandate_id": "215066c6-9d23-435e-8154-f02df48b44fd",
"status": "pending"
}
The charge is reserved and processed asynchronously; poll the mandate's
status or the merchant view to see it move to
captured.
Want to know when this settles, without polling in a loop? Register a
merchant webhook endpoint and subscribe to
draw.captured/draw.failed— moyasar-ac pushes the event to you. Webhooks
are best-effort, though;GET /v1/draws/:idbelow remains the endpoint you
reconcile against.For a
livemandate, the platform-wide kill switch is re-checked a
second time immediately before the actual capture call — not just at
reservation time — since capture happens later in a background job on
its own schedule. If the kill switch is thrown after a draw is reserved
but before it captures, the draw fails closed: it stayspending
(neverfailed, and the reserved hold is never released). Once live
mode is re-armed, a background sweeper picks the draw back up and
retries the charge — a kill-switch strand is provably never sent to
the payment processor, so resuming it is safe.sandboxdraws are
never affected.A draw whose charge response was lost is resolved automatically.
If a charge was sent to the payment processor but its response never
came back, the platform looks the payment up by a deterministic
identifier derived from the draw itself, and settles it either way: if
the payment exists it is reconciled tocapturedorfailedas
normal; if the processor confirms no such payment was ever created, the
draw is failed and its reserved stock released. Where the charge does
have to be re-sent, the processor's own idempotency returns the original
payment rather than creating a second one, so a draw is never charged
twice.A draw only stays
pendingindefinitely when that lookup itself cannot
be completed — the processor is unreachable, so there is no evidence in
either direction. Those draws are surfaced to an operator for manual
reconciliation rather than guessed at, because recording a failure that
did not happen would release stock and mandate headroom for a payment
that may have succeeded, and there is no automated refund path to
reverse it. Treatpendingas "outcome not yet known", never as
"not charged".
Draw errors
| Status | error |
Meaning |
|---|---|---|
401 |
unauthorized |
The Authorization: Merchant … key isn't one of this merchant's live keys — wrong key, or a key that has passed its retirement date or been disabled (see Key states) |
401 |
signature_invalid |
Signature mismatch, or X-Merchant-Id doesn't match the authenticated merchant |
401 |
timestamp_skew |
X-Timestamp more than 5 minutes from server time |
404 |
mandate_not_found |
X-Mandate-Id doesn't exist |
409 |
nonce_replayed |
This X-Nonce was already used on this draw key |
409 |
idempotency_conflict |
X-Idempotency-Key reused with a different request body |
403 |
pin_mismatch |
This mandate's draw key is pinned to a different merchant |
422 |
amount_invalid |
amount_minor ≤ 0 |
422 |
cart_signature_malformed |
cart is missing, or agent_signature/merchant_signature isn't a well-formed {alg, kid, value} envelope |
422 |
cart_signature_invalid |
A cart signature's kid doesn't match the signer's registered signing_kid, or the signature doesn't verify against the canonical cart bytes |
422 |
cart_terms_mismatch |
The cart is validly signed, but its total_minor doesn't equal the request's amount_minor — or (when the cart declares them) its currency/intent_mandate_id doesn't match the request/mandate |
403 |
merchant_not_portal_approved |
The merchant's portal application status isn't approved — applies to both sandbox and live mandates (unlike merchant_not_live_enabled, which is live-only) |
403 |
product_merchant_mismatch |
The mandate is linked to a catalog product (product_id), and the authenticated merchant isn't the merchant that owns that product |
422 |
mandate_not_approved |
Mandate isn't approved — it has expired, was revoked, or is suspended (moyasar-ac found its recorded outcomes disagreeing with the payment processor and paused it pending operator reconciliation) |
422 |
currency_mismatch |
currency doesn't match the mandate |
422 |
cap_exceeded |
This draw would exceed total_cap_minor or per_draw_cap_minor |
422 |
period_cap_exceeded (+ period_start, period_end, max_per_period_minor, spent_this_period) |
Recurring mandate's per-period ceiling would be exceeded |
503 |
live_mode_kill_switch |
Mandate is live and the platform-wide live-mode kill switch is on |
422 |
token_env_mismatch |
Mandate is live but its payment token is missing or isn't itself live |
403 |
merchant_not_live_enabled |
Mandate is live but the merchant isn't enabled for live-mode draws |
422 |
live_volume_cap_exceeded (+ currency, cap_minor, spent_today_minor) |
Mandate is live and this draw would push today's total live spend, across all mandates, in this currency, over the admin-set daily_live_volume_caps_minor |
422 |
live_mandate_draw_cap_exceeded (+ cap_minor, spent_today_minor) |
Mandate is live and this draw would push today's spend on this mandate alone over the admin-set daily_live_draw_cap_per_mandate_minor |
409 |
product_out_of_stock |
The mandate is linked to a catalog product (product_id) and the product's available stock (stock_on_hand - reserved_count) is less than product_quantity. The reservation is a single atomic conditional UPDATE, so two concurrent draws against the last unit can never both succeed |
409 |
product_already_purchased |
The mandate is linked to a catalog product whose unit has already been captured. A product-linked mandate may consume at most product_quantity units in total, so a second charge against the same listing is refused |
409 |
product_draw_in_flight |
The mandate is linked to a catalog product and an earlier draw against it is still settling. Only one draw at a time may hold a product mandate's reservation. Retry once the in-flight draw reaches a terminal state — if it fails, its reservation is released and a fresh draw is accepted |
Stock moves in two steps rather than one. A draw against a
product-linked mandate reserves product_quantity units — it
increments the product's reserved_count and leaves stock_on_hand
untouched — which is what makes the units unavailable to anyone else.
The reservation then resolves exactly once:
- Captured: the units are consumed.
stock_on_handandreserved_countboth drop, because the units have genuinely left the merchant's shelf. - Terminal failure (webhook or charge response reports one): the
reservation is released. Only
reserved_countdrops;stock_on_handis never touched, because the units were never sold. A legitimate retry is then treated as a fresh attempt rather than being wrongly blocked or double-counted.
Because a failure never writes stock_on_hand, a merchant can restate
their on-hand figure at any time — including while a draw is still
settling — without that edit being inflated or deflated by the draw's
later outcome.
Only one draw at a time may hold a product mandate's reservation. A second
draw attempted while the first is still settling is refused with
product_draw_in_flight rather than sharing the reservation — together with
product_already_purchased, that is what enforces the rule that a product-linked
mandate consumes at most product_quantity units in total. A draw attempted
after an earlier one failed is accepted normally, because the failure released
the reservation.
A retried request with the same X-Idempotency-Key and the same
semantic fields (method/host/path/body-hash/mandate/merchant/amount)
returns the original response instead of creating a second charge —
safe to retry on a network timeout.
Merchant webhooks
Subscribe your endpoint to the draw lifecycle events you care about and
moyasar-ac pushes them to you as they happen. Nothing is delivered until
you both register an endpoint and subscribe it to an event type —
an unsubscribed merchant receives nothing.
Webhooks are best-effort. Polling is authoritative.
Delivery is retried on a fixed ladder and then given up on; a network
partition, a redeploy of your receiver, or an endpoint that fails for
long enough can all mean an event you were supposed to get never
arrives. Treat webhooks as a low-latency notification channel and the
polling endpoints below as the source of truth you reconcile against.
Registering an endpoint
Endpoints are registered from the merchant portal, at /merchant/webhook
— there is no API for this in v1. The flow:
- Submit your
https://URL and pick the event types you want (draw.reserved,draw.captured,draw.failed). - moyasar-ac immediately sends the challenge handshake to that URL. Nothing is delivered until it succeeds.
- On success, the portal shows you a signing secret once. It is never shown again and isn't recoverable — store it before you navigate away.
There is no recovery path for a lost secret in v1. Changing your
endpoint's URL re-runs the challenge handshake, but it does not mint a
new secret — the secret is set once, at registration, and never
changes for the life of the endpoint. There is also no way to
re-register: a merchant may only ever have one endpoint, and
registering again while one already exists is refused. Secret
rotation is explicitly out of scope for v1 (a future kid-based
dual-validity rotation is schema-possible, not shipped). If you lose
the secret, there is nothing to do about it from the portal today.
The registration and edit forms refuse a URL that:
- doesn't use
https:// - carries userinfo (
https://user:pass@host/...) - resolves (at registration time, and again every time we're about to send) to a private, loopback, link-local, CGNAT, or multicast address, or to one of moyasar-ac's own hostnames
- uses any port other than 443 — see below
moyasar-ac never follows a redirect while registering or delivering to
your endpoint: a 3xx response is treated as a failure, not a hop to
follow.
Port 443 is required. Your endpoint must be https:// on port 443.
A URL with any other port — for example
https://hooks.example.com:8443/mac — is rejected at registration and
never contacted. If your receiver listens elsewhere, put it behind a
443 front door.
Editing your subscribed event types alone does not re-trigger the
challenge — nothing about proof of control over the URL changed.
Changing the URL itself always re-demotes the endpoint to
unverified and re-runs the handshake, because control of the old
address proves nothing about the new one.
| Condition | Result |
|---|---|
https://hooks.example.com/mac |
Accepted, challenge sent |
https://hooks.example.com:8443/mac |
Rejected — port 443 required, never contacted |
http://hooks.example.com/mac |
Rejected — must be https:// |
https://user:pass@hooks.example.com/mac |
Rejected — must not contain credentials |
https://10.0.0.5/mac |
Rejected — private/loopback/link-local/CGNAT/multicast addresses are refused |
The challenge handshake
On registration, and again on any URL change, moyasar-ac POSTs your
endpoint:
{ "type": "webhook.verification", "challenge": "<random token>" }
Respond 2xx and echo the token back, in either of two shapes:
{ "challenge": "<the same token>" }
or as the raw response body — a plain-text <the same token> with
nothing else in it.
Anything else — a non-2xx status, a redirect, a timeout, TLS failure,
or a body that doesn't echo the current token — fails verification, and
your endpoint stays (or becomes) unverified. Every failure mode shows
the merchant the same one generic message:
Verification failed — check that your endpoint echoes the challenge
over HTTPS.
This is deliberate: a message that distinguished "refused" from
"timed out" from "wrong body" would turn the registration form into a
port-scan oracle against arbitrary hosts. The specific reason is logged
internally; only the generic message reaches the portal.
There is exactly one other message, and it is not a verification
result:
We're checking another endpoint right now — wait about a minute and
try again.
Only one verification runs at a time across the whole platform, so if
another is in flight yours is declined without your endpoint being
contacted at all. Nothing about your endpoint changes — not its status,
not its challenge token. It is reported separately from a failure
precisely because telling you verification failed when we never called
you would send you rewriting a handler that works. It says nothing about
any address, so it is not the oracle the rule above prevents.
No event is ever delivered to an unverified endpoint.
Verifying a delivery signature
Every delivery carries five headers:
| Header | Description |
|---|---|
X-MoyasarAc-Event |
The event type, e.g. draw.captured |
X-MoyasarAc-Delivery |
This delivery's id — stable across every retry, see below |
X-MoyasarAc-Timestamp |
Unix seconds, at send time (changes on every retry) |
X-MoyasarAc-Signature |
v1=<hex HMAC-SHA256>, computed below |
Content-Type |
Always application/json |
To verify a delivery:
- Build the signed string:
"#{X-MoyasarAc-Timestamp}.#{raw request body}"— the timestamp header, a literal., then the exact bytes of the request body (no re-serialization). - Compute
HMAC-SHA256of that string using your endpoint's signing secret (shown once at registration), and lowercase-hex-encode it. - Compare your result to the value after
v1=inX-MoyasarAc-Signature, using a constant-time comparison — not==. - Reject the delivery if
X-MoyasarAc-Timestampis further from your own clock than the skew tolerance you choose to enforce. moyasar-ac doesn't impose one on you; pick something that comfortably covers clock drift and retry latency (a few minutes is reasonable).
The version prefix is there on purpose: a future v2 scheme would be an
additional header value, never a replacement of what v1= means, so
verifying against v1= specifically continues to work indefinitely.
Worked example
The exact inputs below are reproduced verbatim from this repo's
MerchantWebhooks::SignTest — the normative source. Run any of these
three samples and you should get exactly this signature back.
- Secret:
3vZq1c9x8Kx0m2p5s7Q_bJ8tW4nR6yF1aH0dL2gU3kE - Timestamp:
1767225600 - Body (300 bytes):
json {"type":"draw.captured","delivery_id":"6f1c2b7e-0d3a-4c5b-9e8f-1a2b3c4d5e6f","occurred_at":"2026-01-01T00:00:00Z","data":{"payment_mandate_id":"7f8639ee-7039-412e-ac87-503b8e9986f1","intent_mandate_id":"b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091","amount_minor":42000,"currency":"SAR","status":"captured"}} - Expected signature:
v1=ba6ca1c8f91cefaa3e8c0f2c3878307d1cd61f3f1b164544d4c2e5cbca20f6ab
SECRET="3vZq1c9x8Kx0m2p5s7Q_bJ8tW4nR6yF1aH0dL2gU3kE"
TIMESTAMP="1767225600"
BODY='{"type":"draw.captured","delivery_id":"6f1c2b7e-0d3a-4c5b-9e8f-1a2b3c4d5e6f","occurred_at":"2026-01-01T00:00:00Z","data":{"payment_mandate_id":"7f8639ee-7039-412e-ac87-503b8e9986f1","intent_mandate_id":"b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091","amount_minor":42000,"currency":"SAR","status":"captured"}}'
printf '%s.%s' "$TIMESTAMP" "$BODY" \
| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1
# => ba6ca1c8f91cefaa3e8c0f2c3878307d1cd61f3f1b164544d4c2e5cbca20f6ab
# Prefix with "v1=" for the full header value.
import hashlib
import hmac
secret = "3vZq1c9x8Kx0m2p5s7Q_bJ8tW4nR6yF1aH0dL2gU3kE"
timestamp = "1767225600"
body = (
b'{"type":"draw.captured","delivery_id":"6f1c2b7e-0d3a-4c5b-9e8f-1a2b3c4d5e6f",'
b'"occurred_at":"2026-01-01T00:00:00Z","data":{"payment_mandate_id":'
b'"7f8639ee-7039-412e-ac87-503b8e9986f1","intent_mandate_id":'
b'"b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091","amount_minor":42000,"currency":"SAR",'
b'"status":"captured"}}'
)
signed_string = f"{timestamp}.".encode() + body
signature = "v1=" + hmac.new(secret.encode(), signed_string, hashlib.sha256).hexdigest()
assert signature == "v1=ba6ca1c8f91cefaa3e8c0f2c3878307d1cd61f3f1b164544d4c2e5cbca20f6ab"
require "openssl"
secret = "3vZq1c9x8Kx0m2p5s7Q_bJ8tW4nR6yF1aH0dL2gU3kE"
timestamp = "1767225600"
body = '{"type":"draw.captured","delivery_id":"6f1c2b7e-0d3a-4c5b-9e8f-1a2b3c4d5e6f",' \
'"occurred_at":"2026-01-01T00:00:00Z","data":{"payment_mandate_id":' \
'"7f8639ee-7039-412e-ac87-503b8e9986f1","intent_mandate_id":' \
'"b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091","amount_minor":42000,"currency":"SAR",' \
'"status":"captured"}}'
signed_string = "#{timestamp}.#{body}"
signature = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, signed_string)
raise unless signature == "v1=ba6ca1c8f91cefaa3e8c0f2c3878307d1cd61f3f1b164544d4c2e5cbca20f6ab"
Event reference
| Event | Sent when |
|---|---|
draw.reserved |
A draw is reserved (accepted, charge not yet confirmed) |
draw.captured |
A draw's charge is confirmed captured |
draw.failed |
A draw's charge is confirmed failed |
Every delivery is a JSON envelope:
| Field | Type | Description |
|---|---|---|
type |
string | One of the three event names above |
delivery_id |
string (UUID) | Same value as X-MoyasarAc-Delivery; stable across every retry |
occurred_at |
string (ISO 8601) | When moyasar-ac recorded this event |
data |
object | The draw snapshot, below |
data:
| Field | Type | Presence | Description |
|---|---|---|---|
payment_mandate_id |
string (UUID) | always | This draw's id — the same id GET /v1/draws/:id takes |
intent_mandate_id |
string (UUID) | always | The mandate this draw was made against |
amount_minor |
integer | always | Charge amount in minor units |
currency |
string | always | e.g. SAR |
status |
string | always | The draw's status at the moment this event was built: pending (for draw.reserved — there is no separate reserved status value), captured, or failed |
merchant_idempotency_key |
string | absent for agent-initiated draws | The X-Idempotency-Key you sent when you created this draw |
product_id |
string (UUID) | absent when not catalog-linked | Set only when the draw is against a catalog product |
sku |
string | absent when not catalog-linked | The product's SKU, alongside product_id |
Not included, by design: decline/failure reasons, card or token
data, and any user identifier. The customer relationship is with the
operator, not the merchant, and a merchant has no channel to act on
processor-internal decline classification anyway — see
Errors if you need the shape of a rejected inbound draw
request instead.
Delivery, retries and idempotency
Respond promptly. Every request we make to your endpoint — the
verification challenge and each delivery attempt alike — is abandoned if
connect, send and the complete response haven't finished within 15
seconds, no matter how slowly the bytes arrive. Do your own processing
after you've answered, not before.
A failed attempt (a non-2xx response, a timeout, or any transport
error) is retried on a fixed ladder: 1 minute, 10 minutes, 30
minutes, 1 hour, 2 hours after the previous attempt. That's five
retries plus the original attempt — six attempts total — after
which the event is given up on and shown as exhausted in your portal
delivery log.
X-MoyasarAc-Delivery is the same value on every attempt of a
given event, including retries. De-duplicate on it: if you've already
processed a delivery with this id, it's safe to acknowledge and ignore
a repeat.
If an endpoint racks up 25 consecutive failed attempts (across any
mix of events, not 25 failures of one event), it is automatically
disabled: its undelivered events are dropped, and you'll need to
re-verify it (re-running the challenge handshake)
before anything is delivered to it again. Any single 2xx resets the
consecutive-failure count to zero.
Self-service resend, from the portal, re-queues your exhausted
deliveries from the last 7 days. It doesn't retroactively deliver
anything older, and it's bounded per request — it exists for
"my endpoint was down for an hour, catch me up," not for replaying your
whole history.
Your subscription list is checked only once, when an event is queued
for you — not again right before it's sent. If you unsubscribe from
an event type after one was already queued, that in-flight event is
still delivered; unsubscribing only stops future events from being
queued. (Your endpoint's own verified-vs-disabled status is different:
that is re-checked immediately before every send, so an endpoint
that becomes unverified between emission and send has its queued
events exhausted rather than sent.) This is intentional — a queued
row already represented a correct decision when it was made — but it
means "I just unsubscribed" is not a guarantee that nothing more
shows up.
Ordering — read this
Attempts to a single endpoint are serialized — moyasar-ac never sends
two deliveries to the same endpoint concurrently. But serialized is
not the same as in lifecycle order: because failed attempts retry on
a ladder, a later event can be delivered before an earlier one finally
succeeds. Concretely, a draw.reserved that hits a flaky retry can
arrive after the draw.captured for the same draw.
Do not assume you'll see reserved before captured before failed.
Treat every delivery as an independent fact about a draw at a point in
time, and key fulfillment off draw.captured's own payload rather than
off having previously seen a draw.reserved for the same id.
Limitation: reversals are not notified
A payment this subsystem reports as captured can still be refunded or
voided later, directly at the payment processor. v1 has no
correction event — there is no draw.reversed or equivalent, so
nothing is pushed to you when that happens.
Concretely: if you fulfil (ship a product, grant access, etc.) as soon
as you receive draw.captured, you may be fulfilling against money
that is later reversed. The polling endpoints
below always return the draw's current truth, so re-polling a draw
you consider closed would show you the reversal — but nothing prompts
you to do that re-poll. A draw.reversed event is the planned
successor to close this gap; it does not exist yet.
Polling (authoritative)
Both endpoints below use the same request-signing scheme as
Draws — a merchant API key plus the
nine-field canonical string, HMAC-signed
with your draw_key_secret. Two differences for a GET with no body:
- the body-hash field is
base64url(SHA256(""))— the hash of an empty string, since there is no request body to hash - the
X-Idempotency-Keyfield in the canonical string is empty, and the header itself is omitted from the request entirely (there's nothing to make idempotent about a read)
GET /v1/draws/:id
HOST="localhost:3000"
PATH_="/v1/draws/7f8639ee-7039-412e-ac87-503b8e9986f1"
MANDATE_ID="b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091"
MERCHANT_ID="your-merchant-id"
DRAW_KEY_SECRET="the-draw_key-from-mandate-creation"
NONCE=$(openssl rand -hex 16)
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
BODY_SHA=$(printf '' | openssl dgst -sha256 -binary | b64url)
# The idempotency-key field is empty, so the canonical string must end in a
# bare trailing newline -- built as $(...)$'\n' rather than a trailing %s "",
# because command substitution silently strips a trailing newline captured
# via $(...) and would otherwise sign one byte short of the real string.
CANONICAL="$(printf 'GET\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \
"$HOST" "$PATH_" "$BODY_SHA" "$MANDATE_ID" "$MERCHANT_ID" "$NONCE" "$TIMESTAMP")"$'\n'
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$DRAW_KEY_SECRET" -binary | b64url)
curl "http://$HOST$PATH_" \
-H "Authorization: Merchant your-merchant-api-key" \
-H "X-Merchant-Id: $MERCHANT_ID" \
-H "X-Mandate-Id: $MANDATE_ID" \
-H "X-Nonce: $NONCE" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Signature: $SIGNATURE"
import base64
import hashlib
import hmac
import httpx
import uuid
from datetime import datetime, timezone
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
host = "localhost:3000"
path = "/v1/draws/7f8639ee-7039-412e-ac87-503b8e9986f1"
mandate_id = "b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091"
merchant_id = "your-merchant-id"
draw_key_secret = "the-draw_key-from-mandate-creation"
nonce = uuid.uuid4().hex
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
body_sha = b64url(hashlib.sha256(b"").digest())
canonical = "\n".join([
"GET", host, path, body_sha, mandate_id, merchant_id, nonce, timestamp, "",
])
signature = b64url(hmac.new(draw_key_secret.encode(), canonical.encode(), hashlib.sha256).digest())
resp = httpx.get(
f"http://{host}{path}",
headers={
"Authorization": "Merchant your-merchant-api-key",
"X-Merchant-Id": merchant_id,
"X-Mandate-Id": mandate_id,
"X-Nonce": nonce,
"X-Timestamp": timestamp,
"X-Signature": signature,
},
)
print(resp.status_code, resp.json())
require "net/http"
require "json"
require "openssl"
require "base64"
require "securerandom"
require "time"
def b64url(bytes)
Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end
host = "localhost:3000"
path = "/v1/draws/7f8639ee-7039-412e-ac87-503b8e9986f1"
mandate_id = "b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091"
merchant_id = "your-merchant-id"
draw_key_secret = "the-draw_key-from-mandate-creation"
nonce = SecureRandom.hex(16)
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
body_sha = b64url(OpenSSL::Digest::SHA256.digest(""))
canonical = ["GET", host, path, body_sha, mandate_id, merchant_id, nonce, timestamp, ""].join("\n")
signature = b64url(OpenSSL::HMAC.digest("SHA256", draw_key_secret, canonical))
uri = URI("http://#{host}#{path}")
req = Net::HTTP::Get.new(uri,
"Authorization" => "Merchant your-merchant-api-key",
"X-Merchant-Id" => merchant_id,
"X-Mandate-Id" => mandate_id,
"X-Nonce" => nonce,
"X-Timestamp" => timestamp,
"X-Signature" => signature)
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts res.body
GET /v1/draws/:id response fields
| Field | Type | Presence | Description |
|---|---|---|---|
payment_mandate_id |
string (UUID) | always | This draw's id |
intent_mandate_id |
string (UUID) | always | The mandate this draw was made against |
amount_minor |
integer | always | Charge amount in minor units |
currency |
string | always | e.g. SAR |
status |
string | always | pending, captured, or failed (the draw.reserved webhook event fires while status is still pending — there's no separate reserved value) |
created_at |
string (ISO 8601) | always | When the draw was created |
settled_at |
string (ISO 8601) or null |
always present, null while pending | When the draw reached captured or failed |
notifications |
array | always present (may be empty) | Webhook delivery attempts for this draw, below |
merchant_idempotency_key |
string | omitted entirely when unset | The X-Idempotency-Key you sent when creating this draw |
product_id |
string (UUID) | omitted entirely when not catalog-linked | |
sku |
string | omitted entirely when not catalog-linked |
The optional trio (merchant_idempotency_key, product_id, sku) is
absent as a key, not present-and-null, exactly matching the
webhook payload's "or absent" contract — one shape
across both transports.
Each entry in notifications is one delivery attempt row:
| Field | Type | Description |
|---|---|---|
endpoint_id |
string (UUID) | Your webhook endpoint's id |
event |
string | draw.reserved, draw.captured, or draw.failed |
status |
string | pending, delivered, or exhausted |
attempts |
integer | Attempts made so far |
last_response_code |
integer | Omitted until at least one attempt has been made |
last_attempted_at |
string (ISO 8601) | Omitted until at least one attempt has been made |
notifications never includes a delivery's body or headers — only
ids, statuses and counts.
GET /v1/intent-mandates/:id/draws
Every draw against your mandate, paginated — including draws you never
asked for and hold no id for, because they were created by the flow's
agent or by the hosted approval page rather than by your own
POST /v1/draws call. This is how you catch up on agent-initiated
draws.
Signed exactly like GET /v1/draws/:id above — same canonical string
shape, same empty-body-hash and empty-idempotency-key convention. The
path is different, though, and PATH is one of the nine signed
fields: the samples below are fully self-contained and recompute their
own signature over /v1/intent-mandates/:id/draws — do not reuse the
$SIGNATURE/$NONCE/$TIMESTAMP from the GET /v1/draws/:id sample
above, they were computed against a different path and will not verify
here.
| Query param | Default | Description |
|---|---|---|
since |
none | ISO 8601 timestamp; only draws created strictly after this are returned (exclusive) |
limit |
50 |
Max rows to return; capped at 100 |
PATHin the canonical string is the bare path only —
/v1/intent-mandates/:id/draws, without the?since=...&limit=...
query string.sinceandlimitare not signed; only the path,
method, mandate/merchant ids, nonce, timestamp and (empty) body/
idempotency-key fields are.
HOST="localhost:3000"
PATH_="/v1/intent-mandates/b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091/draws"
MANDATE_ID="b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091"
MERCHANT_ID="your-merchant-id"
DRAW_KEY_SECRET="the-draw_key-from-mandate-creation"
NONCE=$(openssl rand -hex 16)
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
b64url() { base64 | tr '+/' '-_' | tr -d '=\n'; }
BODY_SHA=$(printf '' | openssl dgst -sha256 -binary | b64url)
# The idempotency-key field is empty, so the canonical string must end in a
# bare trailing newline -- built as $(...)$'\n' rather than a trailing %s "",
# because command substitution silently strips a trailing newline captured
# via $(...) and would otherwise sign one byte short of the real string.
CANONICAL="$(printf 'GET\n%s\n%s\n%s\n%s\n%s\n%s\n%s' \
"$HOST" "$PATH_" "$BODY_SHA" "$MANDATE_ID" "$MERCHANT_ID" "$NONCE" "$TIMESTAMP")"$'\n'
SIGNATURE=$(printf '%s' "$CANONICAL" | openssl dgst -sha256 -hmac "$DRAW_KEY_SECRET" -binary | b64url)
# The query string rides along on the request URL; it is NOT part of $PATH_ above.
curl "http://$HOST$PATH_?since=2026-01-01T00:00:00Z&limit=50" \
-H "Authorization: Merchant your-merchant-api-key" \
-H "X-Merchant-Id: $MERCHANT_ID" \
-H "X-Mandate-Id: $MANDATE_ID" \
-H "X-Nonce: $NONCE" \
-H "X-Timestamp: $TIMESTAMP" \
-H "X-Signature: $SIGNATURE"
import base64
import hashlib
import hmac
import httpx
import uuid
from datetime import datetime, timezone
def b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
host = "localhost:3000"
path = "/v1/intent-mandates/b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091/draws"
mandate_id = "b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091"
merchant_id = "your-merchant-id"
draw_key_secret = "the-draw_key-from-mandate-creation"
nonce = uuid.uuid4().hex
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
body_sha = b64url(hashlib.sha256(b"").digest())
# PATH here is the bare path -- the query string below is not part of it.
canonical = "\n".join([
"GET", host, path, body_sha, mandate_id, merchant_id, nonce, timestamp, "",
])
signature = b64url(hmac.new(draw_key_secret.encode(), canonical.encode(), hashlib.sha256).digest())
resp = httpx.get(
f"http://{host}{path}",
params={"since": "2026-01-01T00:00:00Z", "limit": 50},
headers={
"Authorization": "Merchant your-merchant-api-key",
"X-Merchant-Id": merchant_id,
"X-Mandate-Id": mandate_id,
"X-Nonce": nonce,
"X-Timestamp": timestamp,
"X-Signature": signature,
},
)
print(resp.status_code, resp.json())
require "net/http"
require "json"
require "openssl"
require "base64"
require "securerandom"
require "time"
def b64url(bytes)
Base64.strict_encode64(bytes).tr("+/", "-_").delete("=")
end
host = "localhost:3000"
path = "/v1/intent-mandates/b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091/draws"
mandate_id = "b2c3d4e5-6f70-4812-9a3b-4c5d6e7f8091"
merchant_id = "your-merchant-id"
draw_key_secret = "the-draw_key-from-mandate-creation"
nonce = SecureRandom.hex(16)
timestamp = Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
body_sha = b64url(OpenSSL::Digest::SHA256.digest(""))
# PATH here is the bare path -- the query string added to the URI below is not part of it.
canonical = ["GET", host, path, body_sha, mandate_id, merchant_id, nonce, timestamp, ""].join("\n")
signature = b64url(OpenSSL::HMAC.digest("SHA256", draw_key_secret, canonical))
uri = URI("http://#{host}#{path}")
uri.query = URI.encode_www_form(since: "2026-01-01T00:00:00Z", limit: 50)
req = Net::HTTP::Get.new(uri,
"Authorization" => "Merchant your-merchant-api-key",
"X-Merchant-Id" => merchant_id,
"X-Mandate-Id" => mandate_id,
"X-Nonce" => nonce,
"X-Timestamp" => timestamp,
"X-Signature" => signature)
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts res.body
GET /v1/intent-mandates/:id/draws response fields
{
"draws": [ { "payment_mandate_id": "...", "...": "..." } ],
"next_since": "2026-01-01T00:03:12.482910Z",
"has_more": true
}
| Field | Type | Description |
|---|---|---|
draws |
array | Your own draws against that mandate, oldest first. Each entry has the same shape as GET /v1/draws/:id's response. Draws another merchant made against the same mandate are never returned, even if you hold a valid draw key for it |
next_since |
string (ISO 8601) or null |
Pass this as since to fetch the next page; null when has_more is false |
has_more |
boolean | Whether another page is available |
Page forward by re-requesting with since=next_since until has_more
is false. since is exclusive, so re-requesting with the last page's
next_since never returns a row you've already seen.
Polling errors
| Status | error |
Meaning |
|---|---|---|
401 |
signature_invalid |
Signature mismatch, or X-Merchant-Id doesn't match the authenticated merchant |
401 |
timestamp_skew |
X-Timestamp more than 5 minutes from server time |
401 |
unauthorized |
The Authorization: Merchant … key isn't one of this merchant's live keys — wrong key, or a key that has passed its retirement date or been disabled (see Key states) |
404 |
mandate_not_found |
X-Mandate-Id doesn't exist |
404 |
draw_not_found |
The requested draw doesn't exist, or exists but isn't yours — both return this identical body, so the endpoint can't be used to probe which draw ids exist |
400 |
invalid_since |
since isn't a parseable ISO 8601 timestamp |
Passkeys
Agent-driven management of a user's WebAuthn passkey credentials — the
credentials used to sign mandate approvals in the
hosted approval flow. A user can hold multiple
passkeys (e.g. one per device); these endpoints let an agent list them,
send a user a link to add a new one, or revoke one.
Requires a bearer token with the mandates:write scope.
List a user's passkeys
curl "http://localhost:3000/v1/passkeys?user_identifier=traveler@example.com" \
-H "Authorization: Bearer $ACCESS_TOKEN"
resp = httpx.get(
"http://localhost:3000/v1/passkeys",
params={"user_identifier": "traveler@example.com"},
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.json())
uri = URI("http://localhost:3000/v1/passkeys")
uri.query = URI.encode_www_form(user_identifier: "traveler@example.com")
req = Net::HTTP::Get.new(uri, "Authorization" => "Bearer #{access_token}")
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Passkey list response
{
"passkeys": [
{ "id": "3efb21c3-236d-4661-80c5-20b29632d2ba", "label": "Chrome on Linux", "created_at": "2026-07-14T12:31:47Z", "last_used_at": "2026-07-15T09:52:56Z" }
]
}
Returns {"passkeys": []} for a user with no credentials yet (not an
error).
Send a user a link to add a passkey
curl -X POST http://localhost:3000/v1/passkeys \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"user_identifier": "traveler@example.com"}'
resp = httpx.post(
"http://localhost:3000/v1/passkeys",
headers={"Authorization": f"Bearer {access_token}"},
json={"user_identifier": "traveler@example.com"},
)
print(resp.json())
uri = URI("http://localhost:3000/v1/passkeys")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json",
"Authorization" => "Bearer #{access_token}")
req.body = { user_identifier: "traveler@example.com" }.to_json
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts JSON.parse(res.body)
Add-passkey response
{ "add_passkey_url": "http://localhost:3000/passkeys/a1b2c3..." }
Send this URL to the user (e.g. after they report a lost device). It's
always additive — completing it registers a new passkey alongside any
existing ones, never replacing them.
Revoke a passkey
curl -X DELETE http://localhost:3000/v1/passkeys/3efb21c3-236d-4661-80c5-20b29632d2ba \
-H "Authorization: Bearer $ACCESS_TOKEN"
resp = httpx.delete(
"http://localhost:3000/v1/passkeys/3efb21c3-236d-4661-80c5-20b29632d2ba",
headers={"Authorization": f"Bearer {access_token}"},
)
print(resp.status_code)
uri = URI("http://localhost:3000/v1/passkeys/3efb21c3-236d-4661-80c5-20b29632d2ba")
req = Net::HTTP::Delete.new(uri, "Authorization" => "Bearer #{access_token}")
res = Net::HTTP.start(uri.hostname, uri.port) { |http| http.request(req) }
puts res.code
Returns 204 No Content on success.
Passkey errors (all three endpoints)
| Status | error |
Meaning |
|---|---|---|
400 |
user_identifier is required |
Missing on list/add |
404 |
not_found |
Unknown credential id (revoke) |
422 |
cannot_revoke_last_credential |
A user must always retain at least one passkey — revoke the others first, or add a new one before revoking the last |
Hosted Approval Flow
This section is not a set of endpoints you call — it documents what
happens when the user opens the approval_url from
Intent Mandates. Understanding it matters because
your agent needs to know when to expect the user to complete it, and
what "approved" actually guarantees.
1. Sign in (passkey, or OTP as the fallback)
This establishes who is approving the mandate — the mandate was created
against a specific user_identifier, and only that user's session can
approve it.
A user who already holds a passkey signs in with it. The page offers
"Confirm with Face ID or passkey" as the primary action; the emailed code
remains underneath as the fallback. Until this existed the OTP was the only
way to a session — the passkey signed the mandate but never signed the user
in — so every visit from a cookie-less browser (the ChatGPT app's in-app
webview, a fresh phone browser) sent a returning user to their inbox before
the Face ID that would have proven their identity anyway.
Passkey sign-in is authentication, and is deliberately not the approval
ceremony reused:
- The challenge is 32 fresh random bytes minted per attempt and held in the server session — never derived from the mandate, so a sign-in assertion can never be replayed as an approval or vice versa.
allowCredentialsis scoped to the mandate's user: the capability token identifies the mandate, the mandate identifies the user, and only that user's registered credentials can satisfy the ceremony. A valid passkey for another account is refused (422), tested against a genuinely registered second user rather than a fake key.- The challenge is single-use and expires after five minutes; a second assertion on the same challenge is refused even from the same browser.
- Verification is
Webauthn::VerifyAssertion— the same path, with the samesign_countclone check, that mandate approval trusts. - On success the session is created exactly as the OTP path creates it, so the two cannot drift in what "signed in" means.
Mandate details stay behind sign-in: identity first, then the terms, then
a second Face ID to approve. Collapsing those into one ceremony would show the
mandate to whoever holds the link before they had proved anything — a
trust-boundary change kept as a separate decision.
A user with no passkey yet sees only the OTP form. They enter their
email/phone, receive a one-time code, and register a passkey during the first
approval.
The way back into the chat
When the approval link was opened from ChatGPT through openExternal() (the
widget does this automatically in that host), ChatGPT appends a redirectUrl.
The hosted pages remember it — only if it is an https URL on
chatgpt.com, chat.openai.com or claude.ai; anything else is dropped
silently, never stored or echoed, because this is an open-redirect surface —
and the receipt page offers "Back to the conversation". A button rather
than an automatic redirect: the user has just paid and should see that before
being moved.
2. Passkey registration or assertion
If this is the user's first approval ever, they register a WebAuthn
passkey (biometric or platform authenticator) right there — no separate
signup step. If they already have one, they're prompted to sign with it
directly.
The page says it is waiting as soon as the ceremony starts, and if it is
still waiting after 20 seconds it says so again and offers a way to stop
waiting. Failing everything else it gives up on its own after 120
seconds — long enough to unlock a phone and complete a prompt there,
which is the slowest legitimate path, and short enough that a ceremony
that can only ever fail does not sit silent indefinitely.
Whatever ends it, the page says which of these happened rather than
guessing: the wait was abandoned, the user stopped it, the prompt was
closed, or moyasar-ac's own request failed. Approve is re-enabled in
every case.
A ceremony that ends without a signature never changes the mandate: it
stays awaiting_approval and can still be approved afterwards. Only an
explicit Deny revokes it.
Why a passkey, not just the OTP: OTP proves who the user is;
the passkey signature is what cryptographically proves this specific
mandate was approved by this specific device. The signature covers a
challenge that binds the mandate's terms and its identity and a
fresh, single-use ceremony nonce — not just a hash of the terms — so a
tampered approval request fails verification, and so does an assertion
captured from one mandate and replayed against a different mandate
that happens to share identical terms (cap, currency, expiry, intent).
Terms alone aren't enough to tell two such mandates apart; the mandate
id and nonce are what make each approval ceremony unrepeatable.
3. Decision
The user sees the mandate's terms — the amount cap, currency, expiry,
and (for domain-specific intents like flight_booking) the structured
detail: route, dates, cabin.
A product-backed mandate also discloses what is being bought. Any
mandate carrying a product_id renders the product's artwork, title,
merchant, quantity and price on this screen, read from the live product
row at render time — the same row the cart is built from at completion. This
sits alongside the spending ceiling and never replaces it: the cap is the
authority granted, the price is what the purchase costs, and the two are
shown as separate figures. The disclosure lives on the approval page itself
rather than in a domain plugin, so a newly added domain cannot omit it.
If the price moves above the mandate's remaining authority before the
merchant charges, the purchase is refused rather than charged at a price
the user never agreed to (see Catalog).
They approve or deny. Approving signs the
mandate (status → approved, with a service-side Ed25519 signature
alongside the user's WebAuthn signature, both permanently attached).
Denying sets status → revoked — permanent; a denied mandate can
never be approved later, the agent must create a new one.
If the mandate's env is live, a persistent "LIVE MODE — real money
will move" banner is shown at this step — before the user commits to
anything — so the decision to approve is made with full knowledge that
real funds are at stake, not just sandbox test data.
4. Tokenize (add a card)
Only reachable once the mandate is approved, and only when the user has
no card already saved. On approval the service looks for an existing
active payment token belonging to that same user in that same env; if it
finds one it attaches it to the new mandate and the flow skips this step and
step 5 entirely, going straight to a confirmation that the authorization is
active. This is the card-on-file behaviour the mandate model exists for — a
returning user is not asked to re-enter a card they already saved.
The reuse is chosen server-side from the mandate's own user: no token
identity is read from params, the session or any callback, ownership and
env are asserted rather than assumed, and the mandate must already be
approved, so a means of payment is never attached to something the user has
not consented to. It is audited as mandate.card_reused — deliberately a
different event from mandate.tokenized, because no card was tokenized and
no new card data was seen.
When there is no saved card, the user adds one via a hosted Moyasar form. In
this sandbox, use test card 4111114005765430 (frictionless 3-D Secure, no
OTP challenge).
The same live-mode banner from step 3 is shown here too — a live mandate
displays it on every screen where the user is about to commit something
(approval, card entry, and the post-approval dashboard), not just once.
5. 3-D Secure
The card issuer's own verification step. On success, the flow proceeds
automatically — no separate user action needed.
6. Auto-draw and receipt
For domain-specific intents (like flight_booking) that specify an
automatic charge, the first draw fires automatically once the card is
verified — no separate merchant API call needed for this one-shot case.
The user lands on a receipt/confirmation screen; your agent can confirm
completion via check_mandate_status or
GET .../status, or drive it directly with
POST .../complete-booking rather than waiting.
This only fires if the agent already signed the cart — for
flight_booking, and equally for a retail_purchase/generic_purchase
mandate created from a catalog product_id. Before the user reaches this
step, call GET .../cart-preview and
POST .../cart-signature — without a stored
signature, auto-draw finds nothing to attach as agent_signature and the
draw is rejected as a malformed cart, leaving the mandate un-drawn. The
create_intent_mandate MCP tool performs both
steps itself for every product-backed mandate, so an agent going through
MCP normally has nothing extra to do here.
A draw that is refused creates no charge record at all, so the receipt
screen has nothing to confirm. Rather than waiting indefinitely, it shows
the refusal and the reason for it. The screen distinguishes two cases,
because a background sweeper keeps retrying eligible bookings for 24
hours:
- Final —
cap_exhausted(no authorized amount remains) ormandate_not_approved(revoked or expired). The sweeper will never act on these, so the screen says the booking is over, stops updating, and — when there is genuinely no charge record on the mandate — states that nothing was charged and no money has left the user's account. - Not final — every other refusal, including a missing or stale cart signature. The screen names the problem but does not claim the booking is finished, and keeps updating, because the sweeper may still complete it (for instance once the agent calls cart-signature). It never tells the user their money is untouched, since a later automatic attempt may charge them.
A booking that is merely still in flight keeps showing progress as before.
Both domains, two different screens. A flight_booking mandate gets a
dedicated confirmation view (itinerary, airline, booking reference, and an
"Amount charged" line). Every other domain — including a catalog purchase —
gets the generic mandate summary instead, with the refusal notice described
above rendered above it. The wording differs accordingly: the catalog
notice never speaks of "the fare" or "a ticket price", and it carries one
status the flight path cannot produce, price_changed — the product's
price or currency no longer fits the amount the user authorized, so nothing
was charged, and a later inventory sync that puts it back in range lets the
purchase complete on its own. Before this existed, a refused catalog draw
rendered a clean-looking summary that told the user nothing at all.
Timing
The link itself (a "capability token") is single-use across this whole
ceremony but expires 15 minutes after first viewed. A flow that
stalls mid-way (user gets distracted between OTP and tokenize) past that
window fails with an expired-link error — the agent should create a
fresh mandate rather than expect the same link to keep working
indefinitely.
MCP Tools
tools/ac-mcp/server.py exposes this API as six MCP (Model Context
Protocol) tools, so any MCP-capable AI client — Claude Desktop, LM
Studio, claude.ai, ChatGPT — can drive the whole mandate flow through
natural conversation instead of raw HTTP calls. The server sends its own
usage instructions as the MCP instructions field on connection, so a
compliant client primes its model automatically — no manual system
prompt required.
Tool errors
{ "error": "intent_payload_invalid: object at root is missing required properties: return_policy_ack" }
When the underlying HTTP call fails, every tool below returns a single
{"error": "..."} string rather than raising. The string is the API's own
error code, followed by its details when the response carried any (see
Errors) — details is where the offending field name appears,
so intent_payload_invalid alone would tell the model nothing it could
act on or explain to the user.
Only the parts of details that name the failure are included: a string
details verbatim, or, for a JSON-schema failure, each entry's own
message and data_pointer. The schema and the echoed-back payload that
json_schemer also returns are dropped, and the whole string is capped at
300 characters.
register_agent
def register_agent(name: str, description: str | None = None) -> dict
Call this first. Registers the agent (wraps POST /v1/agents)
and persists the returned credentials to a local session file so
subsequent tool calls in the same or a later process can authenticate.
descriptionis accepted by this tool but not currently sent to
the API — the underlying endpoint has no such field. Passing it is
harmless but has no effect.
Returns: {"agent_id": "...", "name": "...", "status": "registered"}
or {"error": "..."}.
browse_catalog
def browse_catalog(user_identifier: str, domain: str | None = None,
currency: str | None = None) -> dict
Wraps GET /v1/catalog. Requires register_agent to have
been called first. Call this before create_intent_mandate when the
user wants to buy a specific catalog product rather than describe an
open-ended purchase. In an MCP Apps host, the result renders as an
in-chat picker — tapping a product there calls create_intent_mandate
directly (which, for a product-backed mandate, also previews and signs its
cart automatically — see below), with no further tool call from the
model needed to get from a tap to an approval link. That is not the same
as "no further steps": the human still has to open the link, approve, and
add a card before anything is charged — this tap only handles mandate
creation (and its cart signature), never payment itself. See
The MCP Apps widget below for how
that rendering works and what a host with no MCP Apps support sees instead.
user_identifier— required, the user's email or E.164 phone, same valuecreate_intent_mandateitself requires. Not sent to the catalog endpoint (the listing is not user-specific) — it exists so the picker widget, whose only input is this call's own arguments, can create a mandate on the user's behalf when they tap a product without asking the model again.domain— optional, e.g."retail_purchase","hotel_booking","car_rental","activity_booking","flight_booking". Omit to browse all domains.currency— optional, one ofSAR/AED/USD. Omit to browse all currencies.
Returns: the same {"products": [...]} shape as the underlying
endpoint (see Catalog), or {"error": "..."} — including
{"error": "No agent registered yet. Call register_agent first."} if
called before register_agent.
Hosts
The same server drives Claude today. ChatGPT support is prepared but has
not yet been confirmed against a real ChatGPT host — see the warning below
before relying on it.
Per OpenAI's Apps SDK documentation, ChatGPT reads the MCP Apps standard this
server already speaks — the ui:// resource URI, the
text/html;profile=mcp-app MIME type, and _meta.ui.resourceUri — and treats
openai/outputTemplate as a compatibility alias for it. Both keys are
advertised on every widget-bearing tool: the standard one is what matters, the
alias covers a build that only looks for the legacy name.
Three ChatGPT-only presentation hints ride alongside and are ignored by hosts
that do not recognise them: openai/toolInvocation/invoking and
.../invoked (status text, ≤64 characters) on the tools, and
openai/widgetDescription plus openai/widgetPrefersBorder on the resource.
The widget looks different in each host and claims exactly the same
things. ChatGPT injects window.openai into the component frame; Claude and
the MCP reference host do not. The card reads that once at startup and stamps
it on <html>, which selects a light palette for ChatGPT's light thread
instead of the dark card built for Claude's. A browser test asserts the
rendered text is byte-identical across the two skins, because a card whose
money claims varied by host would be a far worse idea than a different
background colour.
Tool metadata: declared with
meta={"ui": {"resourceUri": MANDATE_CARD_URI}} (see below), so an
MCP Apps host renders this result as the picker; see
The MCP Apps widget.
create_intent_mandate
def create_intent_mandate(
user_identifier: str, max_amount: float, currency: str, purpose: str,
domain: str = "generic_purchase", per_draw_cap: float | None = None,
valid_for_days: int = 1, mandate_kind: str = "one_shot_multi_draw",
cadence: str | None = None, charge_trigger: str | None = None,
max_per_period: float | None = None, expected_amount: float | None = None,
route: str | None = None, depart_after: str | None = None,
return_after: str | None = None, cabin: str | None = None,
airline: str | None = None,
product_id: str | None = None, product_quantity: int = 1,
) -> dict
Wraps POST /v1/intent-mandates. Requires
register_agent to have been called first (in this or a prior process —
credentials persist). max_amount/per_draw_cap/expected_amount are
whole-currency-unit floats here (e.g. 9000.0), converted to minor
units internally.
For a flight or trip, pass domain="flight_booking" and — after asking
the user — route ("RUH-DXB"), depart_after / return_after
(YYYY-MM-DD), cabin ("economy" / "business") and airline
("Saudia" or "Emirates"; defaults to Saudia when omitted). Each of these
changes the itinerary the merchant actually books, not just how the mandate
reads: airline resolves to a different carrier, merchant DID and flight
numbers.
For a specific catalog product (see browse_catalog), pass product_id
and optionally product_quantity (default 1) instead of relying on
domain/currency — the server derives domain_id, currency, and a
price floor (price_minor * quantity) from the listing itself and
overrides whatever domain/currency you passed, so a stale or
mismatched value there is harmless; a max_amount that converts to less
than that floor is rejected outright (total_cap_minor must be at least) rather than silently accepted. This is what
... for Nx this product
the in-chat picker widget calls when a user taps a product — product_id
and product_quantity are not part of the widget's own browse_catalog
result, they are read off the specific row the user selected. A
product_id-driven call also gets its cart previewed and signed
automatically, before this tool returns — see "Automatic cart signing"
below.
Returns:
{
"intent_mandate_id": "...",
"approval_url": "http://localhost:3000/m/...",
"status": "awaiting_approval",
"message": "The spending authorization is created but NOT yet active. Share this approval link with the user and tell them to open it to review, approve, and add their card:\n\nhttp://localhost:3000/m/..."
}
The message field is deliberately pre-formatted for the model to relay
verbatim — small models reliably echo a message field but often
drop a bare approval_url sub-field when summarizing on their own.
Tool metadata: declared with
meta={"ui": {"resourceUri": MANDATE_CARD_URI}} (see below), so an
MCP Apps host renders this result as the approval card — including for a
widget-initiated picker tap, which turns the picker itself into that same
card in place; see
The MCP Apps widget.
Note this response does not include
draw_key— the raw API
returns it (see Intent Mandates), but this tool
deliberately omits it from what reaches the model, since it's a
merchant secret that shouldn't land in an LLM's context.
Automatic cart signing for flight_booking and every product-backed
mandate. For a flight_booking mandate, or any mandate created with
product_id set, this tool immediately previews and signs the cart itself
(the same steps sign_cart performs below) rather than relying on the
calling model — or, for a product purchase, the picker widget, which never
calls a second tool at all — to remember a separate step.
Gated on domain == "flight_booking" or product_id — a truthy check on
product_id, not product_id is not None, and never on the resolved
domain's name alone. The truthiness matters: Rails' own
Mandates::Create#apply_product! is gated on .present?, so a
product_id of "" is "no product" to the server (it is stored as null,
and the mandate is not completable). Checking is not None would therefore
fire this branch for a caller that sent an empty string, produce a
domain_not_supported cart preview, and hand the model a spurious
"NOTE: the cart could not be signed" for a mandate that was never meant to
have a cart. As for why the domain name is not enough:
domain here is the caller's argument, and for a product-backed mandate
the server always overrides the actual domain_id from the listing itself
(Mandates::Create#apply_product!) — so checking the domain name the
caller happened to pass would be unreliable, and checking the domain name
the server resolved to would also fire for an ordinary, non-product
generic_purchase mandate that was never meant to have a cart at all. Two
real defects motivated this, both found live:
- 2026-08-03: a
flight_bookingmodel skipped the manualsign_cartstep outright, and the user approved and tokenized a card only for the draw to fail withcart_signature_malformedwith no one told a step had been missed. This is what thedomain == "flight_booking"branch of this auto-sign originally fixed. - 2026-09: the picker widget's own tap-to-buy call
(
authorizeProduct()intools/ac-mcp/ui/mandate_card.html) never passes adomainargument at all — the tool's own default is"generic_purchase"— so the original, domain-name-only version of this check never matched a picker tap, and every catalog purchase made through the picker dead-ended atcart_signature_malformedpermanently (that status is not terminal, soBookings::SweepReadyJobre-listed and re-failed it every 2 minutes for 24 hours). Gating onproduct_idinstead — present on every mandate the picker ever creates — closed this for good.
The automatic signer still refuses to sign blindly: it fetches the
server-computed cart preview and checks it against the mandate it just
created before signing, refusing (with an explanation, not a
signature) if the cart's currency doesn't match the mandate's, or if
its total_minor exceeds the mandate's authorized cap. This is safe to
automate because the server already refuses to accept a signature over
a cart it did not itself compute — POST rebuilds the expected
.../cart-signature
cart and byte-compares it (via JCS) before checking the signature — so
the agent can only ever attest to server-computed terms, never terms of
its own invention.
Signing failure (of either kind) never fails mandate creation — the
mandate is still created and valid, and the user can still approve it —
it only appends a note to message:
{
"...": "...",
"message": "...\n\nNOTE: the cart could not be signed — refusing to sign: cart total 900000 exceeds the authorized cap 300000"
}
sign_cart
def sign_cart(intent_mandate_id: str) -> dict
Fetches GET .../cart-preview for the
given mandate, Ed25519-signs it with the agent's locally held private
key, and submits the signature via POST. This is the same
.../cart-signature
signing path create_intent_mandate calls automatically for
flight_booking mandates (see above); calling it again here is
idempotent and harmless, and it's the only way to sign a cart for a
mandate that predates the automatic-signing behavior or whose automatic
attempt was refused (e.g. because the previewed fare changed after the
cap check ran).
Returns: the same shape as POST on success, or
.../cart-signature
{"error": "..."} — including
{"error": "No agent registered yet. Call register_agent first."} if
called before register_agent.
check_mandate_status
def check_mandate_status(intent_mandate_id: str) -> dict
Wraps GET /v1/intent-mandates/:id/status.
Returns: {"status": "approved", "approved": true, "currency": "SAR", in
"total_cap_minor": 30000, "total_drawn_minor": 0, "no_money_moved": true}
the normal case, or {"error": "..."}.
Unlike the REST endpoint it wraps, this tool omits keys rather than sending
them as null — absence here genuinely means "the underlying response did
not carry this". currency, total_cap_minor (the mandate's ceiling, in
minor units), total_drawn_minor (the mandate's cumulative captured total,
in minor units) and no_money_moved are forwarded only if the
GET .../status response actually contained them — which in practice is
always, since that endpoint sends all four unconditionally, including a
genuine 0. This tool does not invent any of them if they were ever
somehow missing: an earlier version defaulted a missing total_drawn_minor
to 0, manufacturing the exact "confirmed zero" a client would trust as
proof nothing was charged — the fix (and the general rule this tool now
follows throughout) is that absence must pass through as absence, never be
read as evidence.
Once a booking attempt exists for a flight_booking mandate, the response
also carries whichever of booking_status, payment_status and
latest_draw_charged_minor the underlying endpoint actually had a value
for, so an MCP client polling this tool sees the booking progress
(working → done/failed) without a second call. These are the same
fields documented on the mandate status
response, with one rename to watch for:
| REST field | MCP tool field |
|---|---|
last_booking_status |
booking_status |
payment_status |
payment_status |
latest_draw_charged_minor |
latest_draw_charged_minor |
total_drawn_minor |
total_drawn_minor |
total_cap_minor |
total_cap_minor |
no_money_moved |
no_money_moved |
latest_draw_charged_minor is the most recent draw only (never the total —
see the mandate status response docs for why it's named that way) and is
never invented: it appears only once payment_status is captured.
total_cap_minor is the authority the user approved, forwarded so a client
can state what a charge was made against — "SAR 240.00 charged of SAR
300.00 authorized" — rather than a bare amount. Pair it with
total_drawn_minor; do not subtract the two and present the result as
spendable headroom, because total_drawn_minor counts captured draws only
and a reserved-but-uncaptured draw would make that difference overstate the
authority actually left.
Tool metadata: declared with
meta={"ui": {"resourceUri": MANDATE_CARD_URI}} (see below), so an
MCP Apps host re-renders the mandate card on each poll; see
The MCP Apps widget.
complete_booking
def complete_booking(intent_mandate_id: str) -> dict
Wraps POST /v1/intent-mandates/:id/complete-booking.
Call it for a flight_booking mandate — or a retail_purchase/
generic_purchase mandate created from a catalog product_id — once
check_mandate_status reports approved: that's the point at which the user
has approved the mandate and added a card, so the booking can go through
immediately instead of waiting for the background sweeper.
Returns: {"status": "..."} — "drawn" (booked), "already_booked"
(calling twice is safe and never double-charges), "not_ready" (the mandate
isn't approved yet or no active card is linked — wait and re-check), or a
refusal. Two refusals are final and should just be reported to the user:
"mandate_not_approved" (revoked or expired) and "cap_exhausted". Every
other refusal may still clear — notably "cart_signature_malformed" and
"cart_signature_invalid", which sign_cart fixes, and (catalog
mandates only) "price_changed" — the product's price or currency no longer
matches what the mandate authorised; it is never charged at a clamped-down
or coerced value, but a later inventory sync can put it back in range, so
re-check and call complete_booking again rather than treating it as final.
See the full status table.
{"error": "..."} on a transport or authorization failure, including
{"error": "No agent registered yet. Call register_agent first."} if called
before register_agent.
The MCP Apps widget (mandate card)
register_agent, sign_cart, and complete_booking are plain tools — their
result is exactly the JSON documented above, rendered however a given client
renders tool output. create_intent_mandate, browse_catalog, and
check_mandate_status carry one more thing: each is declared with
MCP Apps metadata,
{"ui": {"resourceUri": "ui://moyasar-ac/v<content-hash>/mandate-card.html"}}
which tells a compliant host (Claude, Claude Desktop, ChatGPT) to fetch that
resource — a single self-contained HTML file, tools/ac-mcp/ui/mandate_card.html,
served as MIME type text/html;profile=mcp-app — and render it inline in a
sandboxed iframe instead of (or alongside) the tool's raw JSON.
The v<content-hash> segment is MANDATE_CARD_URI's version, a short
SHA-256 hash of mandate_card.html's own bytes computed once at import
(_widget_version in tools/ac-mcp/server.py) — not a manually-maintained
number. This exists because an MCP Apps host caches widget HTML by its
ui:// resource URI and does not refetch when the served bytes change
under an unchanged URI; deriving the version from the file's content means
any edit to mandate_card.html automatically produces a URI no host has
cached yet, on the next redeploy, with nothing to remember to bump. All
three widget-bearing tools and the @mcp.resource registration itself
always reference the same computed MANDATE_CARD_URI constant, so they
can never drift out of sync with each other.
The pre-versioning URI, ui://moyasar-ac/mandate-card.html, stays
registered too — as a permanent alias serving identical bytes, never
removed. No tool advertises it anymore, but a host can cache a tool's
resourceUri as part of its own stale tool-list metadata (independent of
caching the resource body itself); if such a host still calls
resources/read on the old literal, this alias keeps that call resolving
to a real card instead of a 404.
A host with
no MCP Apps support simply ignores the metadata and shows the JSON, so
nothing above stops working on a client that doesn't implement the
extension — the widget is a richer presentation of the exact same tool
result, never a different code path. LM Studio is one such host — it
has no MCP Apps support at all, so register_agent through
check_mandate_status all behave exactly as documented above (plain JSON,
same tool arguments/results), but no picker or mandate card ever renders
there; see Wiring it into a client below for
which transport/client combination is which.
The one HTML resource renders differently depending on which tool produced
the result it was handed, and what that result contains:
| Tool result shape | Widget state | What it shows |
|---|---|---|
browse_catalog's {"products": [...]} |
the picker | One row per product (thumbnail, title, merchant, "N available", price). The thumbnail is the row's own image_data_uri, rendered as <img src="data:image/..."> and nothing else; a product with no artwork — or, defensively, any value that is not a data:image/ URI — keeps the same fixed-size slot and shows a neutral placeholder glyph, never a gap, a broken image, or a network request. The heading shows the number of options so the user does not have to scroll to discover how many there are, and the list scrolls with a faded bottom edge once it overflows — driven by a measured overflow rather than a row count, since whether it overflows depends on the host's own card width and font metrics. A list that already fits is not faded: implying rows that do not exist would be a claim the data does not support. Selecting a row reveals a quantity stepper, bounded by the row's own available figure (which already nets off reserved units) — offering a quantity the catalog cannot supply would only fail later at draw time, after the user had approved it. The running total and the authorized cap both scale with it, because apply_product! validates the cap as a floor over price_minor × quantity. Changing product resets it to 1. This is still one product per mandate — not a basket; the single-row stock reservation and the one-merchant cart signature are built around that. Tapping a row and pressing "Authorize & order" calls create_intent_mandate directly from the widget, with that row's product_id and its own listed price/currency as max_amount/currency — no further tool call from the model, and no re-typed price. The instant the tap registers, the button swaps its label for a spinner and "Creating your authorization…" ([data-authorizing]), reusing the card's existing .spin indicator — this makes no claim about money or consent (there's still no mandate yet), it just says a call is in flight. It clears the moment that call settles, on every outcome (the resulting waiting/failed render replaces the whole picker). |
create_intent_mandate's draft-mandate response (status, approval_url) — including the widget's OWN response to its own picker-tap call |
waiting | Three labeled ways to reach the same approval_url, each covering a failure mode of the others: a QR code labeled "Scan from another device" (the primary, on-stage affordance); a direct Approve on this device link (<a data-approve>, opened with target="_blank"), for a phone-only demo where scanning your own screen isn't possible; and an always-visible Copy link text button (<button data-copy>), never hidden or conditional, because target="_blank" can be silently swallowed by a host that sandboxes this widget's iframe without the allow-popups token — Copy link is the one affordance proven (in tests/test_widget_e2e.py, against a real sandbox="allow-scripts allow-same-origin" iframe) to survive that case. None of the three replaces another — the QR stays prominent regardless of which of the other two the host's constraints allow. A picker tap turns the picker itself into this same card, in place, on its own tool response — it does not wait for or depend on the host rendering anything else (see "Automatic cart signing" above and the 2026-09-08 changelog entries below: the host was measured live to render no second card at all, so the picker becoming its own consent card is the only path that reaches an approvable mandate). If the tool's message carries a NOTE: the cart could not be signed … line, this card shows that line verbatim as a warning and tells the user not to approve yet — on the picker's widget-initiated path the model may never see the result at all, so this card is the only surface the warning can reach a human through. |
An approved mandate (check_mandate_status's response) |
authorized / working / done | Progresses as payment_status/booking_status change across polls — never claims money moved before payment_status == "captured", and only ever states no_money_moved from that same field, never from total_drawn_minor == 0 (see the warning under check_mandate_status above). |
A terminal mandate status (revoked/expired/exhausted/suspended), a tool error, or payment_status: "failed" |
failed | States the reason and, when the mandate genuinely never moved money, says so explicitly. |
A recoverable booking refusal — including catalog's own
price_changed — is deliberately not
failed: only cap_exhausted and mandate_not_approved (the two booking
statuses complete_booking itself treats as final) render as a stopped
state. Everything else, price_changed included, renders as still-in-progress
(no charge claimed either way) so the card keeps polling and self-corrects
the moment a later attempt succeeds, instead of telling the user a
recoverable hiccup was the end of the story.
Product artwork on the card. Tapping a picker row carries that row's
already-rendered thumbnail forward into the card's hero image, so the
product the user chose stays on screen through waiting, authorized,
working and done — one continuous purchase, not an image that vanishes
for the authorize/charge leg and comes back at the end. A picker tap is the
only way the widget ever learns any artwork: neither
create_intent_mandate nor
check_mandate_status carries an artwork field, so
a card reached without one shows a neutral placeholder rather than inventing
a picture for a mandate it knows nothing about. The carried value is cleared
whenever a different intent_mandate_id arrives, so one mandate's product
can never illustrate another's. The hero uses the same single render shape
the picker thumbnail does — <img src="data:image/...">, nothing else.
The widget itself makes no separate network calls of its own for account
data or state — everything it renders comes from the tools/call result (or
a subsequent check_mandate_status poll) it was handed, via the same
JSON-RPC bridge the host already brokers between the widget and this MCP
server. It never fabricates a field it wasn't given: an absent
no_money_moved/total_drawn_minor is treated as absent, the same rule
check_mandate_status itself follows.
Wiring it into a client
See tools/ac-mcp/README.md for exact setup steps for LM Studio and
Claude Desktop (both stdio-based). The short version: point the client
at .venv/bin/python server.py with MOYASAR_AC_BASE_URL set to your
Rails host, restart the client fully after any config change (both LM
Studio and Claude Desktop only read MCP config at their own startup).
Transport: stdio (default) vs. streamable-http
server.py speaks two transports, selected by MOYASAR_AC_MCP_TRANSPORT:
| Value | Behavior |
|---|---|
| unset | Default. The server runs as a stdio subprocess — what LM Studio and Claude Desktop above use, and what bin/demo launches for the offline local demo. |
exactly stdio |
Same as unset — explicit opt-in to the default. |
exactly streamable-http |
Serves MCP over HTTP instead, on MOYASAR_AC_MCP_HOST:MOYASAR_AC_MCP_PORT (default 0.0.0.0:3002). Exists because a browser-based MCP host (a custom connector in claude.ai, for example) cannot spawn a subprocess or hold a stdio pipe at all — only an HTTP-reachable server works for that kind of client. |
| anything else | The process exits immediately with a nonzero status and names the bad value, rather than silently falling back to stdio — a typo here (e.g. inside a container whose only job is to serve HTTP) used to boot stdio silently, bind no port, and leave a proxy health check failing with no diagnostic at all. |
MOYASAR_AC_MCP_PUBLIC_URL is the externally-reachable base URL to report
back (logged at startup, and the value a connector should be pointed at) —
set it explicitly in any deployed environment rather than relying on the
http://localhost:<port> default, since the domain a given deployment is
reachable on isn't something this server hardcodes anywhere.
In streamable-http mode the server also wraps its HTTP app in a permissive
CORS policy (any origin) with Access-Control-Expose-Headers: mcp-session-id
— without that header a browser client's fetch() cannot read the session
ID the streamable-http protocol relies on, and the second request in the
handshake fails with a 400 — and disables FastMCP's default DNS-rebinding
Host/Origin allow-list (which otherwise only permits localhost/127.0.0.1
traffic, rejecting real deployed traffic outright). It also registers a
plain GET /up route (no auth, no backend calls) for a deploy proxy's
health check, since the protocol's own /mcp endpoint never returns a
plain 2xx to an unauthenticated GET.
Precisely what is and isn't domain-agnostic: server.py itself contains
no hardcoded domain outside of comments — it only ever reads
MOYASAR_AC_MCP_PUBLIC_URL/MOYASAR_AC_MCP_HOST from the environment. The
deploy config for a specific environment, config/deploy.mcp.yml,
necessarily does name a real host (agent.naq.sh, in proxy.host,
MOYASAR_AC_MCP_PUBLIC_URL, and MOYASAR_AC_BASE_URL) — the same way
config/deploy.yml's own PUBLIC_HOST/MERCHANT_API_HOST do for Rails.
That's expected: a deploy config for one destination is supposed to name
where it's deploying to. What must stay domain-agnostic is the
application code, and it does.
Not yet live: config/deploy.mcp.yml has the Kamal service
configuration to run this on agent.naq.sh/mcp behind the existing
kamal-proxy — a standalone config, not part of config/deploy.yml, so
that a not-yet-published image or a broken accessory can never block a
fresh kamal setup for the Rails app. As of this writing it has not been
deployed and no browser-based connector has been registered against it —
streamable-http mode is verified locally (a real MCP client completing
the full handshake against a local instance, including a Docker-built
image of it, running as a non-root user) but not yet reachable publicly.
Access control: the capability-URL token
streamable-http mode has no per-caller authentication of its own — every
tool is reachable by anyone who can reach the port. Rather than a static
request header (the connector's own Add-custom-connector dialog exposes
only Name + URL, and a configured header can go unsent) or OAuth (a larger
build than this deployment's timeline allowed — logged as a real
follow-up, not dropped), a long random token is embedded in the URL path
itself: the real endpoint is <MOYASAR_AC_MCP_PUBLIC_URL>/<token>, e.g.
https://agent.naq.sh/mcp/<token>, not the bare /mcp path.
- A request to the bare path, a wrong token, or a correct token with
anything appended after it all get a plain
404— never401/403, and framed identically to a genuine unmatched route within this server (same status, body, andcontent-length, not just the same bytes with different wire framing). The comparison is on raw bytes viahmac.compare_digest(constant-time, and well-defined for a non-ASCII or invalid-UTF-8 path segment too — an unauthenticated request can't turn into a500here).
This 404-matching is not itself the security control, and it does not
extend to the whole deployed host. agent.naq.sh also serves the
Rails app (Rails' own public/404.html, a multi-kilobyte HTML page, on
every path Rails owns) — so a request to /mcp/<any-guess> (a compact,
text/plain 404) and a request to some unrelated path on the same host
(Rails' HTML 404) are trivially distinguishable from each other. An
internet observer can tell something is mounted at /mcp; what they
cannot do is get past it, because access is gated by the token's
unguessability (a random value at least 32 characters long), not by
keeping its existence a secret. The 404-matching above closes a
narrower, real gap — an unauthenticated probe against /mcp/* cannot
distinguish "wrong guess" from "no such route" from "not gated at all" —
it was never a claim that the mount point itself is invisible on the
wider host.
- GET /up (the health-check route above) is deliberately exempt —
a deploy proxy's health check calls it with no token at all, and it
leaks nothing regardless (a static "ok").
- The token is a secret (MOYASAR_AC_MCP_TOKEN, at least 32 characters —
e.g. openssl rand -hex 32), supplied via Kamal secrets — never
written into config/deploy.mcp.yml, never committed. This server's own
log lines never interpolate the value (only whether gating is
enabled), and its HTTP access log is disabled entirely
(uvicorn.run(..., access_log=False)) specifically so the token can't
leak through a request-path log line the way an access logger normally
would. It is unavoidably visible in two other places outside this
server's control: a deploy proxy's own access logs, and whatever the
connector's UI persists once it's configured — both accepted as the
cost of this approach.
- Fails closed, not open: streamable-http mode refuses to start
(nonzero exit) unless MOYASAR_AC_MCP_TOKEN is set to at least 32
characters. A missing or empty value is treated identically — Kamal
secrets resolve a referenced-but-unset shell variable to an empty
string, not an error, so "just don't set it" and "export it as empty"
are the same failure mode and both are refused the same way. Set
MOYASAR_AC_MCP_ALLOW_UNGATED=1 to explicitly opt out and run ungated
for local testing — a second, deliberate variable, so this can't be
triggered by simply forgetting the first one.
Session persistence and identity provisioning in a deployed container
Self-registration (POST /v1/agents) is 404 in production by design
(see Agent errors) — so a freshly-booted, never-provisioned
container's register_agent call has nothing to register against, and the
demo can't get past its first tool call. Production identity is instead
provisioned through configuration: config/deploy.mcp.yml mounts a
dedicated volume and points MOYASAR_AC_SESSION_FILE (an override
server.py already reads — see the tool descriptions above) at a path on
it, and an operator places a pre-minted agent's credentials there before
the container is expected to serve real traffic (the exact procedure —
rake staging:demo_state, then copying its printed credentials in — is
documented inline in config/deploy.mcp.yml).
One consequence worth stating plainly: this makes the deployed container's
agent identity shared — every caller who reaches it (through the
capability-URL token above) acts as the same one provisioned agent, the
same way a local bin/demo run does. That's the intended shape here, not
an oversight — and register_agent's own idempotency guard (return the
existing identity instead of registering a new one once agent is already
present) means a later caller cannot overwrite the provisioned identity for
everyone else, whether or not self-registration happens to be open.
Webhooks
POST /webhooks/moyasar is inbound — Moyasar's payment gateway calls
this endpoint to notify moyasar-ac of payment status changes. This is
not something a partner integrates against directly; it's documented
here for architectural completeness, since it's how
Draws reconcile from pending to captured.
Moyasar authenticates each event with a shared secret_token field in
the payload body (verified with a constant-time comparison). Since a
merchant can hold both sandbox and live Moyasar accounts, the
secret_token is checked against both configured secrets; whichever
one matches determines the event's env (sandbox or live) —
recorded on the stored event so downstream reconciliation re-fetches the
payment from the correct Moyasar account. A secret_token matching
neither secret is rejected with 401 and no env is recorded. Events
are deduplicated by (source, event_id) — a replayed delivery with a
known event_id is acknowledged (200/401 matching the original
verification result) without reprocessing.
| Status | Meaning |
|---|---|
200 |
Event accepted and enqueued for processing (or a legitimate idempotent replay) |
400 |
Malformed JSON, or missing id |
401 |
secret_token didn't match |
Errors
Every error response is a JSON object with at least an error key:
{ "error": "insufficient_scope" }
Some errors (e.g. intent_payload_invalid, period_cap_exceeded) add
extra fields with more detail — see each endpoint's own error table for
specifics: Authentication, Agents,
Intent Mandates, Draws,
Passkeys.
HTTP status codes used across this API
| Status | Meaning |
|---|---|
200 |
Success |
201 |
Resource created |
202 |
Accepted — processing asynchronously (draws) |
204 |
Success, no response body (passkey revoke) |
400 |
Malformed request (missing required field, unparseable body) |
401 |
Missing/invalid credentials, or a request signature that failed verification |
403 |
Authenticated, but not authorized for this action (wrong scope, mismatched pin) |
404 |
Resource not found |
409 |
Conflict — replayed nonce, idempotency-key reuse with a different body |
422 |
Semantically invalid — fails business validation (bad enum value, cap exceeded, schema mismatch) |
429 |
Rate limited |
Changelog
Notable changes to this documentation.
2026-09-14
Passkey sign-in. A user who already holds a passkey now signs in with it;
the emailed OTP is the fallback rather than the default. The passkey had
only ever signed the mandate, never signed the user in, so every visit
from a cookie-less browser — the ChatGPT app's webview in particular — went
to the inbox first. Authentication, not the approval ceremony reused: a
fresh random single-use challenge per attempt,allowCredentialsscoped to
the mandate's user, the sameWebauthn::VerifyAssertionpath. Every guard is
mutation-proven, including against a genuinely registered second user's
passkey. See Sign in.The ChatGPT card opens the approval link through
openExternal(), with
the approval origin declared inopenai/widgetCSP.redirect_domains. A plain
target="_blank"anchor is swallowed by the sandboxed iframe (proven live);
this is ChatGPT's sanctioned route. The hosted pages remember the
redirectUrlChatGPT appends — only forhttpsURLs on trusted chat hosts —
and the receipt offers "Back to the conversation". On a phone the whole
ceremony becomes: tap, Face ID, Face ID, back in the chat.
2026-09-09
The picker has a quantity stepper.
product_quantityhas worked end to
end server-side since catalog completion shipped —apply_product!validates
the cap as a floor overprice_minor × quantityand the cart builder prices
the line from it — but the widget hardcoded1, so the capability was
invisible. The stepper is bounded by the row'savailablecount, resets when
the selection changes, and scales both the running total and the authorized
cap. Still one product per mandate: a true multi-product basket would need a
line-items schema and multi-row stock reservation, which the current
reservation design explicitly avoids."View receipt" is gone from the done card, replaced by "Copy receipt
link". Proven on the deployed host: the anchor did nothing when clicked while
the copy button worked. A host iframe sandboxed withoutallow-popups
swallowstarget="_blank", and the MCP Apps spec has no open-a-link method —
external URLs were deferred from it. A control that looks operable and is not
is the same defect as the inert "Revoke mandate" span already removed.ChatGPT is a supported host. Every widget-bearing tool now advertises
openai/outputTemplatealongside the standard_meta.ui.resourceUri, plus
ChatGPT's invocation-status and widget-description hints. Per OpenAI's
documentation no transport change is required — ChatGPT reads the same MCP
Apps standard this server already spoke, and documentswindow.openaias a
compatibility layer with the standard JSON-RPC bridge preferred — but
this has not yet been confirmed against a real ChatGPT host, and the
widget implements only thepostMessagebridge. The card renders a light palette
in ChatGPT and the dark one in Claude, selected fromwindow.openai
being present; a browser test asserts the rendered text is identical across
both skins, so only the palette varies and never a claim about money.The success card names what was bought, states the charge at 22px and
reads "charged of SAR X you authorized" beneath it. It previously said only
"Booked" with the mandate id, and the charged/authorized pair — the whole
regulatory argument — was the smallest text on the card.Failure copy no longer uses internal vocabulary or unevidenced money
claims. "The payment failed after the draw was reserved" is now "The
payment did not go through, so the purchase was not completed." It
deliberately does not say "nothing was charged":payment_statusdescribes
the attempt, not the outcome for the customer's account, and that claim is
reserved for the evidence gate (no_money_movedplus a confirmed zero).Money is written one way on the hosted pages. All amounts go through a
singlemoney_minor(minor, currency)helper and the locale strings no
longer carry a separate%{currency}token, so "SAR 100.00" can no longer
drift into a bare "100.0" beside it.A malformed approval link is a 404, not a 500.
The demo catalog is eight products, defined once.
staging:demo_state
anddemo:seedeach carried their own copy of the same two products; both
now stock fromlib/demo_catalog.rb, so a booth cannot be restocked to a
different catalog than the one rehearsed against. The picker heading now
states how many options there are, and the list fades its bottom edge once
it actually overflows.
Products carry real photography. An earlier pass in the same day shipped
hand-drawn vector illustrations to keep the inline payload small; they read
as placeholders and were replaced. The size problem is real — every byte of
image_data_uri travels inline in the browse_catalog result and lands in
the model's own context — but the answer is to stop sending images far
larger than the slot they appear in, not to give up photographs. Each is a
128px square JPEG (~4.6KB; ~37KB of base64 for all eight) against a 34px
picker thumb and a 52px slot on the approval page, so it is still 2.4–3.7×
oversampled. Product's own ceiling is 16KB each, which would have cost
~37,000 tokens per listing for no visible gain.
JPEG rather than WebP because Product::DATA_URI_FORMAT accepts only
svg+xml, png and jpeg — and unlike SVG, a JPEG carries no active
content, so it sidesteps the sanitiser question entirely. All images are
CC0; provenance is recorded in db/demo_assets/CREDITS.md.
check_mandate_statusnow forwardstotal_cap_minor. Rails always
returned it; the MCP tool discarded it, so a client could report what was
charged but not what it was charged against. It is forwarded with the
same presence-passthrough rule as the fields around it — a cap that is
merely absent is never invented, because a fabricated ceiling would
misstate the authority the user actually gave. Pair it with
total_drawn_minorto say "SAR 240.00 charged of SAR 300.00 authorized";
do not subtract them and present the difference as spendable headroom
(total_drawn_minorcounts captured draws only, so a
reserved-but-uncaptured draw makes that subtraction overstate what is
left).The approval page discloses what is being bought. A product-backed
mandate now shows the product's artwork, title, merchant, quantity and
price beside the spending ceiling, read from the live product row.
Previously the screen showed only the cap and a product title — no
merchant, quantity, image or price — while the chat card the user tapped
showed all four. The disclosure lives on the approval page rather than in
a domain plugin, so a new domain cannot omit it.The card form is skipped when the user already has a saved card. On
approval, an existingactivepayment token belonging to the same user in
the sameenvis attached to the new mandate, and the tokenize and 3-D
Secure steps are skipped. Chosen server-side with no token identity read
from params, session or callback; ownership andenvasserted; the
mandate must already beapproved. Audited asmandate.card_reused,
deliberately distinct frommandate.tokenized— no card was tokenized.Flight mandates: the agent is told to ask before it spends. The
server's MCPinstructionsandcreate_intent_mandate's own description
now direct the model to ask for dates, cabin, airline preference and
budget in one message before creating the mandate, and to accept "no
preference" and move on.route,depart_after,return_after,cabin
andairlinewere always accepted, but the guidance only captured them
"if the user mentions" them and never namedairlineat all — so they
were rarely populated. Each genuinely changes the booked itinerary:
airlineresolves to a different carrier, merchant DID and flight
numbers.
2026-09-08
- Catalog products can now carry inline artwork — and the security
writeup for it changed direction mid-review. The
Catalog response gained an
image_data_urifield:nullwhen a product has no artwork, otherwise adata:image/svg+xml,data:image/png, ordata:image/jpegbase64 URI — never a remote URL, since the picker widget loads nothing from the network and can only render what arrives inline in the tool result. Capped at 16KB of the raw string's bytes (Product::MAX_IMAGE_BYTES, checked viabytesize, not Rails' character-countinglength:), and the payload must decode as valid base64 for every MIME type, not just SVG.
Two review rounds tried to make the SVG content check itself the security
boundary. Round 1 added <script>/<foreignObject> and event-handler
rejection plus an href/xlink:href scan; review found a
namespace-prefixed <svg:script> that defeated the element check
entirely, a DOCTYPE-declared entity that hid markup from every check, and
external references reachable through url(...) in a <style>/
presentation attribute, @import, <font-face-uri src>, and an
<?xml-stylesheet?> PI — all fixed in round 2. Round 3's review then
found the fixed version still had three bypasses of the same class —
CSS escape sequences, an unquoted @import, and a SMIL <set that installs a reference only when a
attributeName="href" to="...">
browser's animation engine runs it — each confirmed reaching a real
Chromium tab. The check was chasing an unbounded tail: a hand-written
sanitizer trying to out-parse a browser's CSS/SMIL engines.
The decisive fact, also from round 3's own Chromium trace: <img makes zero network requests —
src="data:image/svg+xml;base64,...">
not even for the payloads the content check rejects — because a browser
treats an SVG loaded that way as a static image resource, full stop. So
the security boundary is now the render path: the widget renders
artwork only via <img src="data:...">, and
test_artwork_only_ever_renders_via_img_src_data_uri
(tools/ac-mcp/tests/test_mcp_apps.py) enforces that as a structural
invariant on the widget's own source, written ahead of the artwork
rendering itself (Task 3) so that work cannot introduce inline <svg>,
a CSS background-image, or an <object>/<embed>/<iframe> without
the test catching it. The model-side check (renamed
Product#image_data_uri_looks_safe /
#svg_payload_has_no_obvious_red_flags) stays, explicitly demoted to
best-effort — cheap, catches accidents and unsophisticated hostility, but
documented with its own known, named gaps rather than advertised as
complete. One thing tightened rather than loosened: a nested data: value
is exempt only as data:image/..., not as any data: subtype.
The demo and staging seeds (lib/tasks/demo.rake,
lib/tasks/staging.rake) write real artwork into this column for both
seeded catalog products, and the widget renders it — see the next entry.
- The widget now renders that artwork — in every picker row, and on the
mandate card itself for the whole life of one purchase. A
browse_catalog row shows the product's own
image_data_uri as a thumbnail; a product with none keeps the same
fixed-size slot and shows a neutral placeholder glyph instead, never a
gap or a broken image. Tapping a row carries that row's already-rendered
artwork forward into the card's hero image, so the waiting,
authorized, working and done states all show the product the user
actually chose — it does not appear, vanish for the authorize/charge leg,
and reappear at the end. The artwork is only ever known this way: neither
create_intent_mandate nor
check_mandate_status carries an artwork field,
so a card reached without a picker tap shows the neutral placeholder
rather than inventing a picture for a mandate it knows nothing about, and
the carried value is cleared the moment a different intent_mandate_id
arrives — one mandate's product is never used to illustrate another's.
Both surfaces render artwork only as <img src="data:image/...">,
and the widget now enforces that scheme itself: any value that is not a
data:image/ URI falls back to the placeholder instead of being put in
an src, so the widget's "loads nothing from the network" property no
longer rests entirely on server-side validation of the field. Two
Chromium request-listener tests (tests/test_widget_e2e.py) watch the
browser's own network stack — not just DOM attributes — while the picker
and while each artwork-bearing card state is on screen.
- The waiting card now offers a direct "Approve on this device" link
beside the QR, plus an always-visible "Copy link" fallback. Previously
the only approval affordances were a QR code and a copy-link button
hidden unless the QR itself failed to draw — on a phone-only demo,
scanning a QR rendered on the same screen it's displayed on isn't
possible. The QR (now labeled "Scan from another device") stays the
primary, on-stage affordance; a new <a data-approve> pill, routed
through the same approval_url and navigated to directly (no fetch,
no XHR — plain browser navigation), gives the phone-only case a tappable
path without displacing the QR. Because a real MCP Apps host renders
this widget in a sandboxed iframe and target="_blank" navigation can
be silently swallowed by a sandbox that omits allow-popups, a
<button data-copy> "Copy link" control is now unconditionally
visible — not just a QR-draw-failure fallback — so a swallowed approve
tap still leaves a way to get the link onto another device. Proven
against a real restrictive iframe (sandbox="allow-scripts, deliberately without
allow-same-origin"allow-popups) in
tests/test_widget_e2e.py. See the
MCP Apps widget state table.
- The picker now becomes the consent card in place on its own
create_intent_mandate response — the collapsed widget state is
removed. Previously, a successful "Authorize & order" tap rendered a
one-line summary ("Shawarma Plate · SAR 42 ✓ · Continuing below.") that
assumed the host would render a second card carrying the approval QR.
Measured live in a real Claude client: it does not — the host answers
only the picker's own tool call and renders nothing else, so that summary
permanently stranded the user with an awaiting_approval mandate whose
one-time approval_url (minted once by CapabilityTokens::Mint; only a
hash is persisted server-side) was never shown anywhere reachable. The
picker now routes its own response through the same state machine a
host-pushed tool result already uses, landing on the waiting state —
real QR, real polling, and the unsigned-cart NOTE: warning all included
— regardless of what the host does afterward. See the
MCP Apps widget state table.
- The picker shows visible progress while create_intent_mandate is in
flight, instead of a disabled button and dead air. Tapping "Authorize &
order" already disabled the button (see Review Critical 2, above); it now
also swaps the button's own label, immediately and synchronously, for a
spinner (the same .spin element the rest of the card already uses) and
the text "Creating your authorization…", marked [data-authorizing]. The
indicator needs no separate cleanup path: every one of authorizeProduct's
exits — success, a tool error, the tool's own {"error": "..."} shape, or
the call rejecting (including the existing 20-second timeout) — ends by
calling render() with a new template, which replaces the picker (button
and indicator both) wholesale. See the
MCP Apps widget state table.
2026-09-07
- New
complete-bookingoutcome:price_changed. Aretail_purchaseorgeneric_purchasemandate created from a catalogproduct_idcan now be completed the same way aflight_bookingmandate is. Unlike the flight cart builder, which chargesmin(price, remaining_cap), the catalog cart builder rejects withprice_changedwhenprice_minor × quantityexceeds the mandate's remaining cap (countingper_draw_cap_minortoo, when one is set), when the product's currency no longer matches the mandate's, or when its quantity isn't a positive integer — it never clamps or coerces a real product to a price or currency the merchant or the user never agreed to. Unlikecap_exhausted/mandate_not_approved,price_changedis not terminal: a merchant's inventory feed can re-list the same SKU (polled every 15 minutes) and put it back within the mandate's authority, so a later retry can still succeed. See the complete-booking status table. - Cart-preview,
cart-signature, and the background sweeper
now accept every completable domain, not just
flight_booking. Aretail_purchase/generic_purchasemandate backed by aproduct_idcan now preview its cart, have an agent sign it, and be drawn — bycomplete-booking, the sweeper, or both — the same way aflight_bookingmandate already could. Previously both endpoints (and the sweeper's candidate list) hardcodedflight_booking, so a within-cap catalog mandate had no way to attach an agent signature and read backcart_signature_malformedrather thandrawn. - Cart-preview and
cart-signature now return
422 {"error": "price_changed"}(new rows on both error tables) instead of a bare500when a catalog product's price, currency, or quantity no longer fits the mandate — reachable at any time, sinceInventorySources::Syncre-lists a merchant's SKUs every 15 minutes. Previously onlycomplete-bookinghandled this case; the other two endpoints let the sameDomainPlugins::PriceExceedsCapescape unhandled. - The receipt page now triggers auto-draw for a
catalog mandate the moment the user lands on it after adding a card, the
same as it already did for
flight_booking— previously only aflight_bookingmandate got this prompt trigger, and a catalog mandate had to wait for the next background sweep (up to 24h). It also now shows the correct pending amount and remaining cap on that same first load, reflecting the draw the page itself just reserved rather than the pre-reserve figures. - Both
price_changederror table rows and the complete-booking status table row now mentionper_draw_cap_minor, not just the overall cap — a product whose price exceeds a mandate'sper_draw_cap_minor(fixed at mandate creation, and never changed afterward by anything in this API) reads backprice_changedeven when it comfortably fits the mandate's overall remaining cap. - Catalog products now carry
availableandmerchant_name.availableisstock_on_hand - reserved_count, not rawstock_on_hand— units already reserved by someone else's in-flight draw were previously invisible to callers even though they aren't buyable. Both fields exist so an MCP client can render a picker row ("only N left", who's selling) without a second round trip;browse_catalogreturns the same shape unchanged.
2026-08-06
- New: Inventory feed. Publish your catalog as a
single JSON endpoint, connect it from
/merchant/inventory, and moyasar-ac polls it every 15 minutes and keeps your Catalog listings in step with it — instead of typing products in one at a time. The section documents the envelope and every item field with its exact constraint, a conforming example, thenextcursor and its same-origin rule, the traversal caps (20 pages, 5,000 items, 2 MB per page, 30s per page, 120s of wall clock), the optionalAuthorization: Bearerheader, and a validation-error table carrying the exactreasonstrings a rejected item is recorded with. - Read Stock is overwritten, not adjusted
before connecting one. A sync writes
stock_on_handabsolutely — it never adds, subtracts, or reconciles. If your inventory system doesn't already deduct orders placed through moyasar-ac, every poll re-inflates our count back to yours and the difference is sold twice. A sale that captures mid-sync deliberately loses to the feed's number as well. - Three more behaviours worth knowing before you build the endpoint.
A product that stops appearing in your feed is
deactivated, not deleted;
an incomplete traversal
deactivates nothing and doesn't count as a fresh read; and omitting
an optional field blanks it — including
cancellation_policy_textandreturn_policy_text, which are copied into the signed mandate and shown on the page a human authorizes with a passkey. activeis a strict boolean and askuis never trimmed. The string"false"is rejected rather than coerced (a serializer that stringifies booleans would otherwise be unable to take a product off sale), and a SKU with surrounding whitespace or a control character is refused rather than cleaned up (stripping would let two feed rows collide on one product). See Item fields.- A feed that fails 10 consecutive runs is disabled, polling stops, and your products stay on sale — being unable to reach your endpoint says nothing about your catalog. Re-enabling is a portal action that re-fetches one page and checks it first. See When your feed keeps failing.
2026-08-04
- Merchant API keys rotate without an integration outage. A merchant
used to hold exactly one API key, so replacing it meant a hard cutover:
the old key stopped working the instant the new one existed. Rotating
from
/merchant/api-keysnow mints the new key while the one it replaces keeps authenticating, so two keys are valid at once for a grace period you choose. Nothing about the request you send changes —Authorization: Merchant <key>is still one key per request, and either live key is accepted. - That grace period is bounded, and you set it. Rotation refuses to proceed unless you name a retirement date for the key being replaced, strictly in the future and no more than 90 days out — an old key with no deadline is a permanent hole, and a date far enough out amounts to the same thing. You may also disable a key outright at any point before that date, which is the path to use when a key has leaked and can't wait out the grace period. At most two keys may be live at a time, so rotating in a loop can't accumulate valid keys — and because the cap counts live keys rather than outgoing ones, disabling a freshly rotated key that leaked leaves you free to rotate again immediately.
- Expiry is enforced when the key is used, not by a background job.
A retirement date is exact to the request: the last call before it
passes succeeds and the next one does not, and disabling takes effect
on the very next request. Both refusals come back as the same
401 {"error": "unauthorized"}a wrong key has always returned — there is no separate "expired" error, so treat a sudden401on a working integration as "this key is no longer live". The Draw errors and Polling errors tables now say so. - Each key records when it was last used. The portal shows the last time each key successfully authenticated a request — the direct way to confirm your integration has actually moved to the new key before the old one retires. See Key states and When a key stops working.
2026-08-03
- New: Complete a booking. A flight booking no
longer has to wait for the background sweeper (or for the user to load
the receipt page) before it is placed.
POST /v1/intent-mandates/:id/complete-booking— and the matchingcomplete_bookingMCP tool — lets the agent complete an approved, card-linked booking the moment it is ready, so an agent that says "booking now" means it. The endpoint grants no new authority: the draw still goes through the same validation path a merchant's would, it is serialized on the mandate's draw key so repeated calls can produce at most one booking, and every outcome (including a refusal such ascap_exhausted) comes back as astatuson a200. - A refused booking now says so instead of spinning. A draw that is
rejected leaves no charge record behind, so the hosted booking screen
had nothing to read and showed "completing your booking…" forever — the
worst possible reading of a booking that had already been refused. It
now renders the reason (an exhausted authorization, a missing or stale
cart signature, a revoked mandate, and so on) in both English and
Arabic. Two of those reasons are treated as final and end the screen:
cap_exhaustedandmandate_not_approved. Every other refusal is one the background sweeper will retry, so the screen names the problem but does not say the booking is over — it keeps updating, and it does not tell the user their money is untouched when a later automatic attempt may still charge them. "Nothing was charged" is now shown only when there is genuinely no charge record on the mandate. See Auto-draw and receipt and the complete-booking status table. complete_bookingno longer answersnot_readyfor a mandate the user revoked.not_readymeans "wait and re-check", which was a lie for a mandate that had been revoked or had expired — an agent could poll it forever. Those now return the terminalmandate_not_approvedinstead.not_readykeeps its meaning: not approved yet, or no active card yet.- The passkey step no longer goes silent. On a device with no
passkey set up, the browser could hold the ceremony open for its own
180-second default while the approval page showed nothing at all —
indistinguishable from a frozen page. The page now says it is waiting,
says so more clearly after 20 seconds and offers a way to stop, and
gives up on its own after 120 seconds. Failures are no longer reported
with one catch-all message: the page distinguishes the wait being
abandoned, the user stopping it, the prompt being closed, and
moyasar-ac's own request failing — so a problem on our side is never
reported as a problem with your device. A ceremony that ends without a
signature does not change the mandate: it stays
awaiting_approval. The same treatment now covers the add-a-passkey page. See The hosted approval flow. - Approval signatures now bind the specific mandate, not just its terms. The passkey challenge previously covered a canonical hash of the mandate's terms alone (cap, currency, expiry, intent) — so two mandates that happened to share identical terms shared the same challenge, and a signed approval captured for one could be replayed against the other. The challenge now also binds the mandate's id and a single-use ceremony nonce, so an assertion is only ever valid for the exact mandate and ceremony it was signed for. Nothing in the flow's external shape changes. See The hosted approval flow.
2026-08-02
- New: Merchant webhooks. Register an endpoint
from the merchant portal and subscribe to
draw.reserved,draw.captured, anddraw.failedto be notified as your draws settle, instead of polling in a loop. Covers the challenge handshake, theX-MoyasarAc-*signature scheme (with a byte-for-byte worked example), the retry ladder and 25-consecutive-failure circuit breaker,X-MoyasarAc-Delivery-based idempotency, and the fact that delivery order across events is not guaranteed. - New:
GET /v1/draws/:idandGET /v1/intent-mandates/:id/draws. The authoritative polling counterpart to webhooks — the second endpoint in particular is how you catch up on agent-initiated draws whose ids you never saw. Both use the same HMAC request-signing scheme as Draws, with an empty-body hash and noX-Idempotency-Keyfor theGET. - Documented limitation: webhooks and polling both report a draw as
capturedand stop there — v1 has nodraw.reversedevent, so a payment later refunded or voided at the processor is never pushed to you. See Limitation: reversals are not notified.
2026-07-30
- New mandate status:
suspended, observable viaGET /v1/intent-mandates/:id/status. moyasar-ac now independently re-checks every draw it marked terminal against the payment processor. When a draw's recorded outcome disagrees with what the processor reports, its mandate's accounting is provably wrong, so the mandate is suspended: draws against it are refused with422 mandate_not_approveduntil an operator reconciles it manually. Nothing in the request or response shape changed, and no draw's own status is ever rewritten by this check. See Intent mandates and Draws.
2026-07-29
- A draw whose charge response was lost is now resolved automatically
by looking the payment up under a deterministic identifier derived from
the draw, rather than being left
pendingfor a human. Found to exist, it reconciles; confirmed absent, it fails and releases its stock. Only a draw whose lookup cannot be completed at all stayspending. See Draws. - A draw whose charge outcome is unknown stays
pendinginstead of being markedfailed. If a charge reached the payment processor but its response was lost, and the mandate's authorization then lapses (expiry or revocation) before that resolves, the draw is leftpendingfor manual reconciliation. Previously it was markedfailed, which released the reserved stock and the mandate's hold for a payment that may have succeeded — with no automated refund path to reverse it.pendingmeans "outcome not yet known", never "not charged". See Draws. - Corrected: the kill-switch note in Draws previously said nothing automatically re-triggers a blocked charge. A background sweeper now resumes such draws once live mode is re-armed.
- Inventory is now tracked as on-hand plus reserved. A draw against a
product-linked mandate reserves units rather than decrementing stock;
the reservation is consumed on capture and released on failure. "In
stock" everywhere — the catalog filter and the mandate-creation
check — now means available stock (
stock_on_hand - reserved_count). No request or response shape changed. See Draws. POST /v1/draws— new409 product_draw_in_flight. Only one draw at a time may hold a product mandate's reservation; a concurrent second draw is now refused rather than sharing it. A draw after an earlier failure is unaffected.POST /v1/draws— new409 product_already_purchased. A
product-linked mandate may consume at mostproduct_quantityunits in
total, so a second charge against an already-captured listing is now
refused rather than silently drawing again.Documented:
intent_payloadschemas for thehotel_booking,
car_rental,activity_booking, andretail_purchasedomain
templates, which shipped on 2026-07-27 without a field reference. See
Intent Mandates.Documented: on a product-driven mandate,
intent_payload.quantity
is forced to matchproduct_quantity. This was already the behavior;
it just wasn't written down.POST /v1/intent-mandates—currencymay now be omitted on a
product-driven request (one carryingproduct_id), since it's derived
from the listing anyway. Previously the request was rejected before that
derivation was considered. Sending acurrencyexplicitly still works
and is still overridden by the product's own value. (domain_idcould
already be omitted this way; that's now written down too.)
2026-07-27
- New:
GET /v1/catalog— an authenticated agent can browse the in-stock, active product catalog of portal-approved merchants, with optionaldomain_id/currencyfilters. See Catalog.
2026-07-20
POST /v1/agentsnow accepts an optionalpublic_keyfield, for an agent that holds its own Ed25519 signing key rather than having moyasar-ac mint one on its behalf.- New:
GET /v1/intent-mandates/:id/cart-previewandPOST /v1/intent-mandates/:id/cart-signature— an agent can preview a flight-booking mandate's deterministic cart terms and submit its own signature over them ahead of the actual draw. See Cart signatures and the Intent Mandates endpoint reference. POST /v1/draws— thecartfield is now required and must carry a valid Ed25519 signature from both the agent and the merchant; a cart that's missing, malformed, cryptographically invalid, or whose signed terms (total_minor/currency/intent_mandate_id) don't match the actual charge is rejected. New error codes:cart_signature_malformed,cart_signature_invalid,cart_terms_mismatch.
2026-07-17
- This documentation is now hosted at a stable, permanent URL and redeploys automatically whenever it's updated — you're always looking at the current version, not a locally-built copy.
- Each page's footer now shows a short build identifier, so you can confirm which version of the docs you're viewing.