> Lettrove docs 1.x · https://docs.lettrove.com/docs/server/tokens

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

```mermaid
sequenceDiagram
  participant P as Your page
  participant S as Your server
  participant G as api.lettrove.com
  P->>S: POST /lettrove-token (your login cookie)
  S->>G: POST /embed/v1/tokens<br/>Authorization: Bearer lt_sk_…
  G-->>S: { token, expiresAt }
  S-->>P: { token }
```

## With `@lettrove/node`

```ts
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:

```ts title="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](/docs/get-started/quickstart#2-the-token-server),
[Next.js](/docs/get-started/nextjs#3-the-token-route), [SvelteKit](/docs/get-started/sveltekit#3-the-token-route).

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

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

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

```python title="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

```php title="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

```go title="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

```ruby title="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](https://github.com/FasterXML/jackson) for the JSON (`com.fasterxml.jackson.core:jackson-databind`):

```java title="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:

```json
{ "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.
