clave docs

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