Skip to main content

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:

app/lettrove-token/route.ts (Next.js, App Router)
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.idRequired. Your own id for the person, 1–256 characters. Their designs are kept under it.
originRequired. 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.
ttlOptional. 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​

mint_token.py
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​

mint_token.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​

mint_token.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​

mint_token.rb
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):

MintToken.java
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…" }
StatuscodeMeansDo
400request_invalidThe body is not as above; the message names the fieldFix the request
401key_invalidNo secret key, a wrong one, or one revokedCheck the key in your server's environment
403origin_not_allowedorigin is not on the project's sites for this key's environmentAdd it in Settings → Embed → Sites, or use the test key on localhost
403project_suspendedThe project is pausedResume it in Settings → Embed
409user_erasedYou asked to erase this person; no token is issued for them—
429rate_limitedOver 600 token requests a minute for this keyWait Retry-After seconds
503service_unavailableThe embed is briefly unavailableRetry 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.