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

Source: https://emailmovers.com/mydatahub/docs/spending-safely
Updated: 4 October 2026

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.

> **Not yet live.** MyDataHub v2 is built and tested locally and is not yet live. This page describes the contract the API is tested against, and the launch date is to confirm. Stripe Checkout top-ups and Stripe Shared Payment Tokens are planned and not built.

## The three layers

1. **Quote first.** An order without a live `quote_id` fails with "Call quote_order first; a quote is valid for 24 hours" and nothing is charged. It is idempotent on the quote plus the `Idempotency-Key`, so a retry never double-charges.
2. **Human proof.** A person confirms the spend, through a form, through an approval page, or because they set up an agent key.
3. **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`.

```json
{
  "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](https://modelcontextprotocol.io/specification/2026-07-28/basic/patterns/mrtr). 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.method` reads `elicitation_form`.
- **Declined, cancelled or left unticked.** Nothing is placed and nothing is charged. The result is `confirmation-required` with the quote kept, so the agent does not ask again.
- **Hosts without forms.** The server uses [URL-mode elicitation](https://modelcontextprotocol.io/specification/2026-07-28/client/elicitation) to the approval page, which returns the token.
- **Nobody to ask.** The tool returns the `confirmation-required` problem with the `approval_url`.

> **Today.** A form is offered only when a form can speak for the caller. That is always so for a signed-in person behind the host. It is not so for an API key, because nothing proves a person sees a form that a key holder's client answers. In the current build every MCP caller is a key, so a standard key is sent to the approval page, and the form becomes the normal path once OAuth is live.

## 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](https://emailmovers.com/mydatahub/docs/authentication.md#standard-keys-and-agent-keys).

```json
{
  "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-Key` still 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](https://emailmovers.com/mydatahub/docs/errors.md), starting with [confirmation-required](https://emailmovers.com/mydatahub/docs/errors/confirmation-required.md) and [agent-budget-exceeded](https://emailmovers.com/mydatahub/docs/errors/agent-budget-exceeded.md).
- Create keys with [Authentication](https://emailmovers.com/mydatahub/docs/authentication.md).
- Get told the moment an order is ready with [Webhooks](https://emailmovers.com/mydatahub/docs/webhooks.md).
