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.

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

ActionSlugPriceWhat it does
Review requestreview-request$0.12Ask 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.

FieldTypeRequiredWhat it is
channel"sms"yesHow to reach the customer. Decides which contact field is mandatory.
customerobjectyesThe person being asked for a review.
customer.namestring (≤ 120 chars)noOptional display name for the customer.
customer.phonestringnoE.164, e.g. +15125550123. Required for sms.
customer.emailstring (email)noRequired for email.
businessobjectyesWho 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.idstring (≤ 128 chars)yesThe business this is sent on behalf of. It is the sender of record, so it must be a business we can resolve.
business.namestring (≤ 160 chars)yesGoes into the message as the business asking for the review.
business.review_urlstring (uri)yesWhere the review is left. http(s) only.
consentobjectyesHow and when this customer agreed to be contacted. Required — sends are refused without it.
consent.basis"existing_customer" | "written" | "verbal"yesHow 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_atstring (date-time)yesWhen consent was obtained. ISO-8601. Refused if it is in the future.
consent.sourcestring (≤ 200 chars)yesWhere the consent came from: a form name, an invoice id, a call recording id.
notestring (≤ 280 chars)noOptional 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.

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.

StatuserrorSent?What to do
400invalid_jsonnoBody was not valid JSON. Fix the request; retrying unchanged will not help.
400invalid_requestnoBody 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.
400invalid_idempotency_keynoIdempotency-Key is longer than we accept — the message says the limit. Use a UUID.
400send_refusednoThe 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.
402noPayment 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.
402payment_requirednoThe 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.
404unknown_actionnoNo such action. The URL takes the hyphenated slug published in the catalog, not the underscored id. GET /api/v1/actions.
409idempotency_key_conflictnoThat Idempotency-Key was already used by a different wallet or with a different body. Keys are per job — mint a new one.
409request_in_flightmaybeAn 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.
429rate_limitednoToo many requests from your address. Honour the Retry-After header.
500internal_errormaybeSomething 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.
502send_timeoutmaybeThe send service did not answer in time. The request may have arrived and the message may have gone out. Not charged; key held.
502send_unreachablemaybeCould not reach the send service. Treated exactly like a timeout: not charged, key held.
502send_failedmaybeThe send service failed on its side, possibly after sending. Not charged; key held.
502send_unconfirmedmaybeThe send service returned 200 without a request id, and we refuse to call that success. Not charged; key held.
503payments_not_configurednoThis deployment cannot take payments, so nothing here is callable. Unlike the coach rail there is no sandbox fallback.
503action_not_configurednoThis deployment has no send service configured. Returned before any price is quoted.
503idempotency_unavailablenoWe 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

Something here wrong or missing? Tell us at hello@gardenpatch.xyz. Include the request id if you have one.