Docs · Action API v1
Pay us to do something, not to say something
A coach call buys an answer. An action buys a side effect in the real world: a message actually reaches an actual person, with consent checked before it goes and opt-outs honoured after. You get back a tracked id, not a fire-and-forget acknowledgement.
Same payment rail as the coaches — an unpaid POST returns 402 with the price, you pay, you retry. New here? Start at the docs index, or read the coach API if you want advice rather than an outcome.
Why this is a separate rail
Three differences drive every design decision below, and they are all consequences of the same fact: this endpoint touches a real person.
- It costs us money per call. A mode's marginal cost is model tokens. An action's is a carrier fee we pay whether or not you are happy with the result.
- It is not safely repeatable. Serving a stored answer twice is free. Sending a review request twice annoys a customer, and at volume it is a pattern carriers can see. So idempotency here fails closed: if we cannot guarantee a retry is not a second send, we refuse the call rather than risk it.
- It carries consent obligations. Nobody has to opt in to receive a diagnosis. Everybody has to opt in to receive a text.
What you are buying is not access to a messaging provider. It is the unglamorous half nobody wants to build: consent records, opt-out handling, per-tenant number provisioning, delivery tracking, and the gating logic itself.
What you can buy
| Action | Slug | Price | What it does |
|---|---|---|---|
| Review request | review-request | $0.12 | Ask one customer for a review over SMS (SMS only for now), with consent checked, opt-outs honored, and the follow-up handled. Returns a tracked request id, not a fire-and-forget send. |
One URL shape: POST /api/v1/actions/{slug}/call. Use the slug exactly as published — the hyphenated spelling is the only one that routes, and substituting the underscored id returns 404. The live version of this table is GET /api/v1/actions.
The request contract
Rendered from the published schema for review-request, the same document served at request.json.
| Field | Type | Required | What it is |
|---|---|---|---|
| channel | "sms" | yes | How to reach the customer. Decides which contact field is mandatory. |
| customer | object | yes | The person being asked for a review. |
| customer.name | string (≤ 120 chars) | no | Optional display name for the customer. |
| customer.phone | string | no | E.164, e.g. +15125550123. Required for sms. |
| customer.email | string (email) | no | Required for email. |
| business | object | yes | Who the request is sent on behalf of. For SMS this is the sender of record: it owns the consent, and it is where a carrier complaint lands. |
| business.id | string (≤ 128 chars) | yes | The business this is sent on behalf of. It is the sender of record, so it must be a business we can resolve. |
| business.name | string (≤ 160 chars) | yes | Goes into the message as the business asking for the review. |
| business.review_url | string (uri) | yes | Where the review is left. http(s) only. |
| consent | object | yes | How and when this customer agreed to be contacted. Required — sends are refused without it. |
| consent.basis | "existing_customer" | "written" | "verbal" | yes | How the customer agreed to be contacted. existing_customer is the post-service case a review request normally rides on; written and verbal are explicit opt-ins. |
| consent.obtained_at | string (date-time) | yes | When consent was obtained. ISO-8601. Refused if it is in the future. |
| consent.source | string (≤ 200 chars) | yes | Where the consent came from: a form name, an invoice id, a call recording id. |
| note | string (≤ 280 chars) | no | Optional text appended to the message. Capped, because it reaches a real person over a channel we are accountable for. |
Conditionally required: when channel is "sms", customer.phone is required. A field marked required above is required on every call; these are the ones that depend on what you asked for.
Consent is a required field, and deliberately awkward
We cannot verify from here that you really hold consent, and the fulfilment side re-checks its own suppression list before anything goes out regardless. The block is required anyway, because making you state how and when consent was obtained means an agent cannot stumble into sending marketing texts without ever encountering the question. It also gives us a record to produce if a carrier asks.
A timestamp in the future is refused. So is a review URL that is not http or https — that string ends up in a message we send on your behalf, and the scheme check is the difference between a review link and an arbitrary payload we deliver for the price of the call.
What comes back
A tracked id and a status of queued. We do not claim delivery here, because at this point in the flow it has not happened yet and saying otherwise would be the most useful lie we could tell you.
{
"action": "review_request",
"request_id": "rreq_01j7y5m2zt7k3q9v8b4n6x0c2d",
"channel": "sms",
"status": "queued",
"business": "Bluebonnet HVAC",
"receipt": {
"id": "rcpt_9f2c41a8b0d34e7f5c1a6b8e0d2f4a13",
"url": "/api/v1/receipts/rcpt_9f2c41a8b0d34e7f5c1a6b8e0d2f4a13",
"expires_at": "2026-09-13T14:22:10.441Z"
}
}Retrying without sending twice
Send an Idempotency-Key and a retry with the same key, body and wallet returns the original result instead of sending again, for 24 hours. Send none and we derive one from your wallet and body for 10 minutes, so a plain retry is still safe.
To force a genuine second send — the same customer, deliberately asked again — use a new key. That is the only way to get one, which is the point.
Errors, and whether anything was sent
Every error body is { error, message, ... }. Branch on the code, never the message. The column that matters most on this rail is the last one: whether a message may already have gone out.
| Status | error | Sent? | What to do |
|---|---|---|---|
| 400 | invalid_json | no | Body was not valid JSON. Fix the request; retrying unchanged will not help. |
| 400 | invalid_request | no | Body did not match the action's schema. issues[] names each path and problem, and schema links the contract. Checked before any price is quoted, so it costs nothing and reserves no key. |
| 400 | invalid_idempotency_key | no | Idempotency-Key is longer than we accept — the message says the limit. Use a UUID. |
| 400 | send_refused | no | The send service refused before sending: a suppressed number, no consent record it would accept, a business id it cannot resolve. When it gave its own reason code, that code replaces send_refused. Key released — fix the request and retry. |
| 402 | — | no | Payment required. The body is the x402 payment requirements. A 402 also means the body passed validation — pay and retry the identical request. There is no sandbox on this rail. |
| 402 | payment_required | no | The request reached the handler without a payment identity, which means the payment wrapper did not price this path. You should never see it; if you do, tell us — it is our bug, and nothing was sent. |
| 404 | unknown_action | no | No such action. The URL takes the hyphenated slug published in the catalog, not the underscored id. GET /api/v1/actions. |
| 409 | idempotency_key_conflict | no | That Idempotency-Key was already used by a different wallet or with a different body. Keys are per job — mint a new one. |
| 409 | request_in_flight | maybe | An identical request is still running. Honour Retry-After and retry with the same key: you get the first copy's result, and the message goes out at most once. |
| 429 | rate_limited | no | Too many requests from your address. Honour the Retry-After header. |
| 500 | internal_error | maybe | Something failed at a point we cannot place relative to the send. Not charged. The key is held: a same-key retry replays this answer, a new key is a deliberate second attempt. |
| 502 | send_timeout | maybe | The send service did not answer in time. The request may have arrived and the message may have gone out. Not charged; key held. |
| 502 | send_unreachable | maybe | Could not reach the send service. Treated exactly like a timeout: not charged, key held. |
| 502 | send_failed | maybe | The send service failed on its side, possibly after sending. Not charged; key held. |
| 502 | send_unconfirmed | maybe | The send service returned 200 without a request id, and we refuse to call that success. Not charged; key held. |
| 503 | payments_not_configured | no | This deployment cannot take payments, so nothing here is callable. Unlike the coach rail there is no sandbox fallback. |
| 503 | action_not_configured | no | This deployment has no send service configured. Returned before any price is quoted. |
| 503 | idempotency_unavailable | no | We could not reserve a send-once key, so we refused to send. Coaches serve anyway in this state; actions do not, because a duplicate message cannot be recalled. Honour Retry-After. |
You are charged only when something was actually queued. Every refusal above returns before settlement, so it costs nothing.
Try it before you pay
There is no sandbox on this rail, and that is deliberate: a fake send is indistinguishable from a real one until a customer's phone buzzes. What you can do for free is POST unpaid. A 402 back means your body passed validation and the only thing left is payment. A 400 means it did not, and issues[] names every field to fix. Neither reserves a key or costs anything.
curl -sS -X POST https://gardenpatch.xyz/api/v1/actions/review-request/call \
-H 'Content-Type: application/json' \
-d '{
"channel": "sms",
"customer": { "name": "Dana Reyes", "phone": "+15125550123" },
"business": { "id": "biz_8f21c4", "name": "Bluebonnet HVAC", "review_url": "https://g.page/r/bluebonnet-hvac/review" },
"consent": { "basis": "written", "obtained_at": "2026-08-14T16:04:00Z", "source": "opt-in checkbox on invoice #4471" }
}'
# 402 → the body passed validation; the response carries the price and the payee.
# 400 → it did not; issues[] names each field. No key reserved, nothing charged either way.The paid call
Same wrapper as the coach rail, with one difference worth repeating: the idempotency key here is not a billing nicety, it is the thing standing between a retried call and a customer's second text.
// npm i @x402/fetch @x402/evm viem
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";
import { privateKeyToAccount } from "viem/accounts";
import { randomUUID } from "node:crypto";
const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`);
// Same wrapper as a coach call. x402 v2 takes a scheme client, not a viem
// wallet client — the wallet form throws "this.client.getExtensions is not a
// function" before any request goes out.
const fetchWithPay = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: "eip155:8453", client: new ExactEvmScheme(account) }],
});
// One key per review request you intend to send. Reuse it on every retry of
// THIS send: a same-key retry replays the original result and the customer's
// phone stays quiet. A NEW key is a NEW message to a real person.
const idempotencyKey = randomUUID();
const res = await fetchWithPay("https://gardenpatch.xyz/api/v1/actions/review-request/call", {
method: "POST",
headers: { "Content-Type": "application/json", "Idempotency-Key": idempotencyKey },
body: JSON.stringify({
"channel": "sms",
"customer": { "name": "Dana Reyes", "phone": "+15125550123" },
"business": { "id": "biz_8f21c4", "name": "Bluebonnet HVAC", "review_url": "https://g.page/r/bluebonnet-hvac/review" },
"consent": { "basis": "written", "obtained_at": "2026-08-14T16:04:00Z", "source": "opt-in checkbox on invoice #4471" }
}),
});
if (res.status === 409) {
// request_in_flight: the first copy is still running. Wait Retry-After and
// send the SAME key again to collect its result.
}
const { request_id, status, receipt, send_state } = await res.json();
// status is "queued": accepted and scheduled by the send service. Nothing here
// claims "delivered" — keep request_id for status lookups and support.
console.log(status, request_id, res.headers.get("Idempotent-Replay") === "true" ? "(replay)" : "(fresh)");
// Any >= 400 was not charged. send_state says whether a retry is safe:
// "not_sent" → the key was released; fix the request and retry with the same key.
// "unknown" → the key is held; only a new Idempotency-Key attempts another send.
console.log(send_state ?? "sent", receipt?.expires_at ?? "receipt not stored");Where to go next
- /api/v1/actions — the live catalog, free, and honest about whether this deployment is fulfilling.
- Operating policies — what we store, for how long, rate limits, and what you are charged for.
- Coach API — when you want an answer rather than an outcome.
Something here wrong or missing? Tell us at hello@gardenpatch.xyz. Include the request id if you have one.