Guide
Spending safely
Three layers guard every spend: a machine quote first, a person's confirmation or a pre-authorised agent key, and hard limits. A flag set by a model is never consent.
No order is placed without a live quote and a person's say-so, and an agent can spend only inside limits a human set. Two actions spend credits: create_order (POST /v1/orders) and enrich_contact (POST /v1/contacts/enrich).
A live CRM push needs the same approval, though it costs nothing. Everything else is free, so an agent can explore as much as it likes.
The three layers
- Quote first. An order without a live
quote_idfails with "Call quote_order first; a quote is valid for 24 hours" and nothing is charged. It is idempotent on the quote plus theIdempotency-Key, so a retry never double-charges. - Human proof. A person confirms the spend, through a form, through an approval page, or because they set up an agent key.
- Hard limits. A pre-authorised agent key skips the human step only while its per-order cap covers the quote and monthly budget remains.
Money never moves inside a Claude connector or a ChatGPT app. Credits are deducted in the tool. Top-ups are planned to happen on mydatahub.emailmovers.com through Stripe Checkout, which is not built yet.
Why a flag is not consent
A flag set by a model is never consent. There is no confirmed, confirm or consent member anywhere.
The MCP input schemas refuse unknown members, and the REST order body is strict, so a model cannot set one. A standard API key or an OAuth token alone never spends either. What a person decides never travels in the arguments a model writes.
How an order is confirmed
The order records the method in confirmed_by.method.
| Method | Who confirms | How |
|---|---|---|
app | A person ordering in the app itself | Their signed-in session |
approval_url | A person on the approval page | A confirmation_token you send with the order |
elicitation_form | A person behind an MCP host | A form the host shows |
agent_key | A person who set the key's limits earlier | The order runs inside the key's cap and budget |
A third-party OAuth client or a standard API key alone never counts as app, so there is no API key method.
The approval page and the confirmation token
With no confirmation, POST /v1/orders answers 403 confirmation-required and nothing is charged. The problem carries the quote_id, an expires_at and an approval_url such as https://mydatahub.emailmovers.com/quotes/{id}/approve.
{
"type": "https://emailmovers.com/mydatahub/docs/errors/confirmation-required",
"title": "A person must confirm this spend",
"status": 403,
"code": "confirmation-required",
"action": "order",
"quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3",
"approval_url": "https://mydatahub.emailmovers.com/quotes/qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3/approve"
}Give the link to the person. They sign in and approve, and the page returns a confirmation_token.
Send it as confirmation_token in the order body. The token is bound to that person's sub and to this spend, and it is redeemable once.
It lasts 15 minutes at most, and never longer than the quote, so retry the order promptly after approval because an expired token is refused. The approval page can also place the order itself when the person approves.
The MCP form
On a host with elicitation, create_order returns resultType input_required with a form of one boolean named approve, off by default. Its label states the spend:
Spend 5,000 credits (£1,249.00 ex VAT bought the cheapest way) for 5,000 contacts, 12 month multi-use licence
The requestState is sealed with AEAD, following the multi round trip pattern. It holds the principal, the quote id, a digest of the arguments and a 15 minute expiry, and it is redeemable once. The host asks the person and retries the same call with the answer and the state.
- Accepted. The server mints the same confirmation token the approval page would, runs the order, and
confirmed_by.methodreadselicitation_form. - Declined, cancelled or left unticked. Nothing is placed and nothing is charged. The result is
confirmation-requiredwith the quote kept, so the agent does not ask again. - Hosts without forms. The server uses URL-mode elicitation to the approval page, which returns the token.
- Nobody to ask. The tool returns the
confirmation-requiredproblem with theapproval_url.
Agent keys and budgets
An agent key is an API key with kind agent, made by a signed-in person with a label, allowed_tools, monthly_budget_credits, per_order_max_credits, an expiry and an optional webhook_url. It never holds the admin scope.
| Check | Outcome |
|---|---|
The tool is not in allowed_tools | 403 insufficient-scope, except for the three spends, which ask a person instead of shutting the key out |
The quote exceeds per_order_max_credits or the remaining monthly budget | 403 agent-budget-exceeded |
| The balance is short | 402 insufficient-credits |
| All limits cover it | The order is placed, with confirmed_by.method agent_key |
Limits are checked before the balance, so an agent over its cap sees the 403 even when the balance is also short. Two orders cannot both fit the same budget, because the check runs inside the ledger transaction. A refund counts in the month of the charge it reverses, so a late refund cannot buy back a later month's budget.
When a key spends on the authority of a person who is later removed or disabled, its next order, enrichment or live push answers confirmation-required and needs a person again.
The agent-budget-exceeded problem carries the budget, the quote, an approval_url for the key owner and a top_up_url.
The owner is told only when the key has a webhook_url and an endpoint is registered at that exact URL, so the notice can be signed. See the webhook_url row in Standard keys and agent keys.
{
"budget": { "monthly_credits": 1000, "spent": 200, "remaining": 800, "resets_at": "2026-10-01T00:00:00Z" },
"per_order_max_credits": 500,
"quote_id": "qte_01JB4M2R6T1Y5W8ZAK8Q2V7XN3",
"credits_required": 5000
}When the balance is short
POST /v1/orders answers 402 insufficient-credits with the quote and a top_up_url on mydatahub.emailmovers.com, where a Stripe Checkout page is planned and not built yet. Once a top-up is possible, retry with the same quote_id.
Stripe Shared Payment Tokens for agent wallets are planned and not built. Machine Payments Protocol on the order endpoint is planned for phase 2.
Retries and idempotency
Idempotency-Key is required on POST /v1/orders and POST /v1/contacts/enrich, and accepted on every other creating POST. Over MCP, create_order takes it as the idempotency_key argument.
- The same key with the same request returns the stored response, with
Idempotent-Replayed: true. Keys are kept for the life of the organisation. - The same key with a different request is
409 idempotency-key-reused. - A refusal (402, 403 or 422) is not stored, so the same key works after you fix the cause.
- The ledger has its own key built from the thing being paid for, so a different
Idempotency-Keystill cannot charge one quote twice.
No human, no key
When no person is present and no key covers the spend, no order is created. The agent keeps the quote_id, a human approves it later in the app, and order.ready fires when the records are built.
A colleague's agent in the same organisation can read the quote with GET /v1/quotes/{id} and order it under its own key. Outside the organisation the audience is quoted again on the payer's plan, and the original quote is never charged.
Next steps
- See every problem in Errors, starting with confirmation-required and agent-budget-exceeded.
- Create keys with Authentication.
- Get told the moment an order is ready with Webhooks.