API Reference

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:

  1. An agent registers itself (Agents) and gets credentials.
  2. 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.
  3. 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.
  4. Once approved, a merchant can draw against the mandate — a signed, replay-protected charge request bounded by the mandate's cap.
  5. Every step is appended to a hash-chained audit log, so the full history of who authorized what, and when, is independently verifiable.

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)

Request

Field Type Required Description
name string yes Display name for the agent. Must not be blank.

Agent registration response

{
  "agent_id": "43b53ae5-caf7-464c-a00d-e89ffb154d21",
  "name": "SkyBooker",
  "client_id": "ag_YlTyCQWgXSwI6ElAyJsYEKbY",
  "client_secret": "9k2mZ...40-character-random-string"
}

client_secret is 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

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 One of SAR, AED, USD.
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 flight_booking or generic_purchase — selects which intent_payload schema applies (below).
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.

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).

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_key is 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
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

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
}

status is one of awaiting_approval, approved, or revoked. Only
the agent that created a mandate can query its status — a mismatched
agent or unknown ID returns 404 {"error": "not_found"}.

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: there is no self-service merchant
registration endpoint in this PoC. A merchant_id and API key are
provisioned out-of-band as part of integration onboarding.

Authentication scheme

Every request needs:

Computing the signature

  1. 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
  1. 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).

HOST is a configured value, not the literal address you connect
to:
the server signs against MERCHANT_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 with MERCHANT_API_HOST=localhost:3000 — if your server
uses a different value (the default is moyasar-ac.local), use that
value instead or you'll get 401 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"
BODY='{"amount_minor":420000,"currency":"SAR"}'
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"
body = b'{"amount_minor":420000,"currency":"SAR"}'
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"
body = { amount_minor: 420_000, currency: "SAR" }.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 no line_items, total_minor, agent_signature, merchant_signature — optional structured cart detail.

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.

Draw errors

Status error Meaning
401 unauthorized Bad merchant API key
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 mandate_not_approved Mandate isn't approved, or has expired
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

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.

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).

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. Login (OTP)

The user enters their email/phone and receives a one-time code. 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.

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.

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
canonical hash of the mandate's terms (cap, currency, expiry, intent),
so a tampered or replayed approval request would fail verification.

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. They approve or deny. Approving signs the
mandate (statusapproved, with a service-side Ed25519 signature
alongside the user's WebAuthn signature, both permanently attached).
Denying sets statusrevokedpermanent; a denied mandate can
never be approved later, the agent must create a new one.

4. Tokenize (add a card)

Only reachable once the mandate is approved. The user adds a payment
card via a hosted Moyasar form. In this sandbox, use test card
4111114005765430 (frictionless 3-D Secure, no OTP challenge).

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.

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 three 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.

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.

description is 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": "..."}.

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,
) -> 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.

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.

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.

check_mandate_status

def check_mandate_status(intent_mandate_id: str) -> dict

Wraps GET /v1/intent-mandates/:id/status.

Returns: {"status": "approved", "approved": true} or {"error": "..."}.

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).

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). 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