# Runtime

The runtime API is what a site's own code calls. It is not for an agent.
The platform puts three values in the code of the live site and of its
draft:

| Value | Holds |
|---|---|
| `env.CLAVE_API` | the API origin |
| `env.CLAVE_SITE_KEY` | a key that names this site and no other; on a draft, the draft's own key |
| `env.CLAVE_ENV` | `live` or `draft` |

Send the key as `Authorization: Bearer $CLAVE_SITE_KEY`.

A draft's key works only where a call says it does. Today that is
`GET /v1/runtime/site` (the draft's address), the spam check, which runs
on the draft's own address, mail to the site's own people (see below),
files, which are the draft's own: its code never sees the live site's
files, nor the other way, and test payments (see below). Any other call
answers `403 not_on_a_draft`, and the code shows that the thing is off on
a draft. Use `CLAVE_ENV` to say so before the call. The draft's calls will
open one at a time, each with a test equivalent of the live one.

The key can do only what a site's code may do: take money in, send mail
with a limit, read its own payments, give money back under the rules
below, and keep files. It cannot read or change anything else on the account.

An error answer is
`{ "error": { "code": "...", "message": "...", "retryable": false } }`.
The code is stable. The message is for a person.

## The site's address

`GET /v1/runtime/site`

The answer is `200` with `{ "url": "https://example.com" }`: the address
the site answers at now. This is the site's domain when one is
attached and active, else the address Clave gave it.

A domain can change, so do not keep the value. In code that answers a
request, use the request's own origin. Use this only where no request
exists, for example a cron job that sends a mail with a link in it.

## Send mail

`POST /v1/runtime/mail`

On a draft, mail goes only to a person with a role on the site: the
owner, a collaborator or staff. It comes from Clave's sender with
`[Draft]` before the subject, and needs no domain. A draft has its own
daily count.

```json
{ "to": "buyer@example.com", "subject": "Your order", "text": "It is on its way.", "reply_to": "shop@example.com" }
```

| Field | Rule |
|---|---|
| `to` | one address, required |
| `subject` | one line, at most 200 characters, required |
| `text` | plain text, at most 20,000 characters, required |
| `html` | HTML, at most 100,000 characters, optional |
| `reply_to` | one address, optional |

The answer is `202` with `{ "sent_to": "buyer@example.com" }`. There is
no attachment.

With `html`, the mail has two parts: the HTML, and `text` for mail apps
that do not show HTML. Clave sends the HTML as it is and does not clean
it. Escape all text that a visitor gives (names, messages) before you
put it in the HTML.

The mail goes only from the site's own domain, after the owner turns
mail on for it and the domain is verified (see [Domains](/api/domains)).
Clave's own addresses never send a site's mail. Without a verified
domain the answer is `409 mail_needs_domain`. `mail_paused` means Clave
or the mail service stopped the site's mail for its bounces or complaints,
and no mail goes out (see [Domains](/api/domains)).

A site can send 100 mails in 24 hours, and a draft 20. Over the limit the
answer is `429 mail_limit`. All sites and drafts of one account can send
200 mails in 24 hours together. Over that limit the answer is
`429 account_mail_limit`. A mail that the mail service refuses still
counts.

## Take money

`POST /v1/runtime/payments`

```json
{ "amount_minor": 2500, "description": "One lamp", "reference": "order_123", "hold_minutes": 30 }
```

| Field | Rule |
|---|---|
| `amount_minor` | the amount in the smallest unit of the currency, required |
| `description` | what the payer sees at checkout, at most 120 characters, required |
| `reference` | the site's own id for it, at most 200 characters, optional |
| `hold_minutes` | how long the pay link works. 24 hours when absent, at most 30 days |

The currency is the payout account's. The site's code computes the
amount from its own prices. Never take an amount from the browser. A site
can open 500 payments a day.

On a draft, the same calls take test money only, into the site's draft
account. The payer pays with a test card, such as 4242 4242 4242 4242. The
owner sets up the draft account once, with no form (see
[Payments](/concepts/payments)).
A draft can open 50 payments a day. A draft reads and refunds only its own
payments, and the live key never reads a draft's payment. On a platform
that takes no test payments, a draft's key answers `403 not_on_a_draft`.

The answer is `201`:

```json
{
  "payment": {
    "id": "pay_...", "site_id": "site_...", "amount_minor": 2500, "currency": "eur",
    "description": "One lamp", "reference": "order_123", "status": "held",
    "expires_at": "...", "created_at": "...", "next_action": "..."
  },
  "pay_url": "https://api.heyclave.com/pay/..."
}
```

`status` is one of these:

| Status | Means |
|---|---|
| `held` | open, and nobody paid yet |
| `expired` | held, and `expires_at` is past. The pay link no longer works |
| `paid` | the provider confirmed the money. `paid_at` is set |
| `refunded` | all of it went back |

After a part refund, the status stays `paid` and `refunded_minor` holds
the total that went back. `reference`, `paid_at`, `refunded_minor` and
`refunded_at` are absent when they have no value.

`GET /v1/runtime/payments/{id}` reads it again, with the same `payment`
object. It has no `late` field. Money that arrives after the hold ran out
does not make the payment `paid`: it stays `expired`, with no `paid_at`.
Only the `payment.settled` event says so, with `late: true`.

| Error | Means |
|---|---|
| `400 invalid_payment` | a field breaks a rule above |
| `409 no_payout_account` | the owner has not connected a payout account to the site |
| `409 payout_account_pending` | the provider does not let that account take money yet |
| `409 no_draft_account` | on a draft: the owner has not set up test payments, or the draft account is not active yet. `next_action` says how |
| `501 payouts_disabled` | this platform takes no money |

### Where the payer comes back from `pay_url`

The payer comes back to the site's `payment_return_path` (see
[Sites](/api/sites)). With no path, they see a plain Clave page.

The hostname is the site's attached domain. With no domain, it is the
site's Clave address. A payer of a draft's payment comes back to the
draft's address. It is not always the hostname the payer started
on, so do not keep the only copy of the order in the browser's storage.
Use the address:

| Parameter | Means |
|---|---|
| `clave_payment` | `done` when the payer finished on the provider's page. `stopped` when they used the provider's back link |
| `payment` | the payment's `id`. Read it with `GET /v1/runtime/payments/{id}` |
| `reference` | the `reference` you gave, when you gave one |

A payer can type these values. Show a status from them, but do not mark
the order paid. The `payment.settled` event does that.

A payer who closes the provider's page, or uses the browser's back
button, does not come back through Clave, and the site gets no
parameter.

After `stopped`, the same `pay_url` works again until `expires_at`, while
the payment is `held`.

## The checkout form on the site's own page

`POST /v1/runtime/payments/{id}/checkout`

```json
{ "return_path": "/thanks?order=order_123" }
```

The answer holds `client_secret`, `publishable_key` and, on some payout
accounts, `stripe_account`. `client_secret` is the secret of a Checkout
Session, not of a PaymentIntent. The page loads `https://js.stripe.com/dahlia/stripe.js`,
calls `Stripe(publishable_key, { stripeAccount })`, then
`createEmbeddedCheckoutPage({ fetchClientSecret })`, and mounts it. The
card form is the provider's frame. No card number reaches the site.

Ask for a new session each time the page opens. After the payment, the
payer comes back to `return_path` on the site's Clave address, also when
the site has a domain. On a draft, it is the draft's address. Put the order's id in `return_path`.

The return page shows a status. It must not mark the order paid. The
`payment.settled` event does that. See [Code in a site](/concepts/code).

If the answer is `no_publishable_key`, the owner of an own payout account
sets the key in the app (Account, Getting paid), with `set_publishable_key`
in a chat client, or with
`PUT /v1/account/payout-accounts/{id}/publishable-key`:

```json
{ "publishable_key": "pk_live_..." }
```

The hosted `pay_url` works meanwhile.

| Error | Means |
|---|---|
| `400 invalid_return_path` | `return_path` is not a path on this site |
| `409 hold_expired` | the hold ran out. Open a new payment |
| `409 already_paid` | the payment is paid |
| `409 no_publishable_key` | see above |

## Give money back

`POST /v1/runtime/payments/{id}/refund`

```json
{ "amount_minor": 1000, "reason": "cancelled in time" }
```

`amount_minor` is at most what was paid and not yet given back. `0` or
no amount gives back all of it. The answer is `202` with
`{ "id": "pay_...", "status": "refunding" }`. The payment changes when the
provider confirms, and then the `payment.refunded` event arrives.

The call needs one of these:

- **A person.** Forward the `Clave-Identity` header that the request to
  the site's code carried. The person must have `payments:refund` at the
  moment of the call. See [Staff](/api/staff).
- **The site alone.** The owner turns on `code_refunds` in the site's
  `clave.site.json` (see [Site settings](/concepts/settings)). Then a call with no `Clave-Identity` works, and
  the audit trail says the site gave the money back. Use it for a refund
  that a rule decides, such as a cancellation inside the free window.

A `Clave-Identity` that is sent is always checked. A bad one gets
`403 identity_required`, also when `code_refunds` is on.

| Error | Means |
|---|---|
| `400 invalid_payment` | the amount is more than is left to give back |
| `403 identity_required` | no valid identity, and `code_refunds` is off |
| `403 not_permitted` | the person may not give money back |
| `404 not_found` | no payment with that id on this site |
| `409 nothing_paid` | nobody paid this payment |
| `409 payout_account_gone` | the owner stopped using the account that took it. They refund in their own provider dashboard |
| `403 refund_needs_payout_owner` | the payment is from before the site changed owner. The account that owned it then refunds in its own provider dashboard |

## Check a visitor is a person

The same spam check the platform's forms use, for the site's own form.

`GET /v1/runtime/turnstile` answers with `frame_url`. Show it in an
iframe (300 by 65) in the form. The frame sends the page a message
`{ type: "clave-check", token }` with `postMessage`. Put the token in the
form, and in the action send it on:

Add `&appearance=interaction-only` to the address to show the check only
when the visitor must click. Then the message also has `visible`: make
the frame 0 high while it is `false`, and 65 high while it is `true`.

`POST /v1/runtime/turnstile` with `{ "token": "...", "remote_ip": "..." }`
answers `{ "success": true }` only for a token solved on this site's page.

## Keep files

A site on a plan that runs code can keep files: a photo that staff add,
a PDF for each order. Clave keeps the bytes. Keep the names and who owns
each file in the site's store. A file is data: a restore of the site
does not bring it back.

`POST /v1/runtime/files`. Add `?public=1` for a public file.

The body is the file, with its `Content-Length`. Send its type as
`Content-Type`. Forward the `Clave-Identity` header that the request to
the site's code carried: only a person with a role on the site (owner,
collaborator or staff) uploads, and Clave checks the role at the moment
of the call.

The answer is `201`:

```json
{
  "id": "file_...", "public": true, "content_type": "image/png", "bytes": 48213,
  "path": "/_clave/img/file_...", "uploaded_by": "acct_...", "created_at": "..."
}
```

| Kind | Rule |
|---|---|
| public | a .jpg, .png, .webp, .avif or .gif image, and the bytes must agree. It is served at `path` on every hostname of the site. `?w=640` resizes it |
| private | any bytes. Only the site's code reads it. There is no `path` |

`GET /v1/runtime/files/{id}` answers with the bytes. The answer has
`Content-Security-Policy: sandbox` and `X-Content-Type-Options: nosniff`,
and `Content-Disposition: attachment` for all but a checked image. Return
it to the browser as it is, and the file cannot run as a page of the
site. Check who may see a private file before you return it.

`DELETE /v1/runtime/files/{id}` removes a file and gives its bytes back.
The answer is `204`. A public file can still be served for up to an hour.

`GET /v1/runtime/files` lists the files, newest first, with `usage` and
`limits`. It pages with `limit` and `cursor`.

| Limit | Live | Draft |
|---|---|---|
| one file | 10 MiB | 10 MiB |
| all files | 1 GiB | 100 MiB |
| number of files | 10,000 | 500 |
| uploads in 24 hours | 500 | 50 |

A file that is deleted still counts against the uploads.

| Error | Means |
|---|---|
| `402 plan_limit` | the site's plan does not run code, the file is too large, or the site is full |
| `403 identity_required` | no valid `Clave-Identity` for this site |
| `403 not_permitted` | the person has no role on the site |
| `411 length_required` | the body has no `Content-Length` |
| `400 body_length` | the body is not as long as its `Content-Length` |
| `415 unsupported_type` | a public file that is not an image |
| `429 rate_limited` | the site made 500 uploads in 24 hours |
| `404 not_found` | no file with that id on this site |

---

## Where this page sits

This is one page of the Clave documentation.
Read it as a web page at https://docs.heyclave.com/api/runtime.
Every page has a `.md` address, and https://docs.heyclave.com/llms.txt lists them all.

- Previous: [Store](https://docs.heyclave.com/api/store.md)
- Next: [Staff](https://docs.heyclave.com/api/staff.md)
- Index: [Overview](https://docs.heyclave.com/index.md)
