Minting tokens
The editor opens with a token your server mints. This is the one piece of server code every
integration needs: a route your page calls (its getToken), behind your own login.
With @lettrove/node
import { Lettrove } from '@lettrove/node';
const lettrove = new Lettrove({ secretKey: process.env.LETTROVE_SECRET_KEY! });
const { token, expiresAt } = await lettrove.tokens.create({
user: { id: currentUser.id }, // your own id for the person; nothing else about them
origin: 'https://app.acme.com', // the page the editor opens on: the request's Origin header
ttl: 900, // optional: 60–900 seconds, default 900
});
In a route, take origin from the browser's Origin header — the page that called you:
import { Lettrove } from '@lettrove/node';
const lettrove = new Lettrove({ secretKey: process.env.LETTROVE_SECRET_KEY! });
export async function POST(request: Request) {
const user = await requireSignedInUser(request); // yours
const { token } = await lettrove.tokens.create({
user: { id: user.id },
origin: request.headers.get('origin')!,
});
return Response.json({ token }, { headers: { 'Cache-Control': 'no-store' } });
}
Every framework page shows the same route in its own shape: Express, Next.js, SvelteKit.
@lettrove/node runs on Node 20 or newer and on edge runtimes (it uses only fetch and Web
Crypto). It refuses to run in a browser, where a secret key would be visible to everyone.
Over HTTPS, from any language
POST https://api.lettrove.com/embed/v1/tokens
Authorization: Bearer lt_sk_test_…
Content-Type: application/json
{ "user": { "id": "u_123" }, "origin": "https://app.acme.com" }
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Type: application/json
{ "token": "eyJhbGciOiJFZERTQSIs…", "expiresAt": "2026-10-07T12:15:00.000Z" }
| Field | |
|---|---|
user.id | Required. Your own id for the person, 1–256 characters. Their designs are kept under it. |
origin | Required. The page's origin, e.g. https://app.acme.com. A full URL is reduced to its origin. It must be on the project's allowed sites; a test key also allows localhost. |
ttl | Optional. Seconds the token lasts, 60–900. Default 900. |
Any other field is refused, with a message naming it. In particular, never send a name or an email: Lettrove does not take personal data about your users.
Return only token to your page (and expiresAt if you like). Send Cache-Control: no-store, so
nothing between your server and the browser keeps a copy.
curl
curl -s https://api.lettrove.com/embed/v1/tokens \
-H "Authorization: Bearer $LETTROVE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"user":{"id":"u_123"},"origin":"http://localhost:3000"}'
Python
import json, os, urllib.request
def mint_token(user_id: str, origin: str) -> str:
request = urllib.request.Request(
"https://api.lettrove.com/embed/v1/tokens",
data=json.dumps({"user": {"id": user_id}, "origin": origin}).encode(),
headers={
"Authorization": f"Bearer {os.environ['LETTROVE_SECRET_KEY']}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(request, timeout=15) as response:
return json.load(response)["token"]
print(mint_token("u_123", "http://localhost:3000"))
PHP
<?php
function mint_token(string $userId, string $origin): string {
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer " . getenv('LETTROVE_SECRET_KEY') . "\r\nContent-Type: application/json\r\n",
'content' => json_encode(['user' => ['id' => $userId], 'origin' => $origin]),
'timeout' => 15,
'ignore_errors' => true,
]]);
$body = json_decode(file_get_contents('https://api.lettrove.com/embed/v1/tokens', false, $context), true);
if (!isset($body['token'])) throw new RuntimeException($body['code'] . ': ' . $body['error']);
return $body['token'];
}
echo mint_token('u_123', 'http://localhost:3000'), "\n";
Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
"time"
)
func mintToken(userID, origin string) (string, error) {
body, _ := json.Marshal(map[string]any{"user": map[string]string{"id": userID}, "origin": origin})
req, _ := http.NewRequest("POST", "https://api.lettrove.com/embed/v1/tokens", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+os.Getenv("LETTROVE_SECRET_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := (&http.Client{Timeout: 15 * time.Second}).Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
Token string `json:"token"`
Code string `json:"code"`
Error string `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
return "", err
}
if out.Token == "" {
return "", fmt.Errorf("%s: %s", out.Code, out.Error)
}
return out.Token, nil
}
func main() {
token, err := mintToken("u_123", "http://localhost:3000")
if err != nil {
panic(err)
}
fmt.Println(token)
}
Ruby
require "json"
require "net/http"
def mint_token(user_id, origin)
uri = URI("https://api.lettrove.com/embed/v1/tokens")
request = Net::HTTP::Post.new(uri, {
"Authorization" => "Bearer #{ENV.fetch('LETTROVE_SECRET_KEY')}",
"Content-Type" => "application/json",
})
request.body = { user: { id: user_id }, origin: origin }.to_json
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, read_timeout: 15) { |http| http.request(request) }
body = JSON.parse(response.body)
raise "#{body['code']}: #{body['error']}" unless body["token"]
body["token"]
end
puts mint_token("u_123", "http://localhost:3000")
Java
With Jackson for the JSON (com.fasterxml.jackson.core:jackson-databind):
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
public class MintToken {
private static final ObjectMapper json = new ObjectMapper();
static String mintToken(String userId, String origin) throws Exception {
String body = json.writeValueAsString(Map.of("user", Map.of("id", userId), "origin", origin));
HttpRequest request = HttpRequest.newBuilder(URI.create("https://api.lettrove.com/embed/v1/tokens"))
.header("Authorization", "Bearer " + System.getenv("LETTROVE_SECRET_KEY"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());
JsonNode answer = json.readTree(response.body());
if (!answer.hasNonNull("token")) {
throw new RuntimeException(answer.path("code").asText() + ": " + answer.path("error").asText());
}
return answer.get("token").asText();
}
public static void main(String[] args) throws Exception {
System.out.println(mintToken("u_123", "http://localhost:3000"));
}
}
Run each with your test secret key in the environment, e.g. LETTROVE_SECRET_KEY=lt_sk_test_… python3 mint_token.py.
Each prints a token: a long string starting eyJ (curl prints the whole answer,
{"token":"eyJ…","expiresAt":"…"}).
When it is refused
A refusal is JSON with a stable code, a message for you, and a requestId to quote to support:
{ "error": "http://localhost:3000 is not an allowed live site for this project. Add it on the project's page.", "code": "origin_not_allowed", "requestId": "01J9ZT…" }
| Status | code | Means | Do |
|---|---|---|---|
| 400 | request_invalid | The body is not as above; the message names the field | Fix the request |
| 401 | key_invalid | No secret key, a wrong one, or one revoked | Check the key in your server's environment |
| 403 | origin_not_allowed | origin is not on the project's sites for this key's environment | Add it in Settings → Embed → Sites, or use the test key on localhost |
| 403 | project_suspended | The project is paused | Resume it in Settings → Embed |
| 409 | user_erased | You asked to erase this person; no token is issued for them | — |
| 429 | rate_limited | Over 600 token requests a minute for this key | Wait Retry-After seconds |
| 503 | service_unavailable | The embed is briefly unavailable | Retry with backoff |
With @lettrove/node, each is a LettroveApiError with the same code, status, requestId and,
when rate-limited, retryAfter.
Good practice
- One token per page load, minted when the page asks. The editor asks again before it expires; you do not need to cache tokens.
- Behind your login: only a signed-in person should get a token, and only for their own id.
- The secret key in your server's environment, never in code, a repository, a page or a log.