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.
{ "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).
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).
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
{ "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).
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:
{
"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). 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
{ "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.
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:
{ "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
{ "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-Identityheader that the request to the site's code carried. The person must havepayments:refundat the moment of the call. See Staff. - The site alone. The owner turns on
code_refundsin the site'sclave.site.json(see Site settings). Then a call with noClave-Identityworks, 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:
{
"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 |