Session
Issue and revoke the session token every embedded request carries, and the identity signature that proves who the end user is.
The editor calls these endpoints itself. They are documented so you can read the editor's network traffic, understand every error it can show, and create a session from your server when you need one outside the iframe.
Issue a session token
One endpoint, two modes, decided by what you send:
| You send | Mode | Caller |
|---|---|---|
plugin_id + MailEditor-Origin header | B | The editor, from its iframe on your page. No secret anywhere |
plugin_id + plugin_secret | A | Your server. No Origin header |
POST /public/plugin/v1/session| Field | Type | Required | Notes |
|---|---|---|---|
plugin_id | string | yes | The public Plugin ID |
plugin_secret | string | mode A | Server only. Rejected if an Origin header is present |
origin | string | no | Mode A only. Restricts the token to this origin. Must be on the allowlist |
external_user_id | string | no | Your own user identifier. Templates and images are scoped to it. May not begin with anon: |
identity_expires | integer | see below | Unix timestamp after which the signature is refused. Required with identity_signature |
identity_signature | string | see below | 64 hex characters: HMAC-SHA256 of the payload below, keyed with plugin_secret. Requires external_user_id |
plugin_version | string | no | Integration version, for diagnostics |
From the browser (mode B)
The browser sets Origin and the editor sets MailEditor-Origin. The allowlist is matched against MailEditor-Origin.
Whether identity_expires and identity_signature are required depends on the plugin's identity_mode: required when signed (the default), optional when open, and no browser session is possible when backend. A signature is verified whenever it is sent, so you can ship signing and confirm it works before switching the mode to signed.
POST /public/plugin/v1/session
Origin: https://maileditor.net
MailEditor-Origin: https://app.customer.com{
"plugin_id": "9f3c1e7a-4b21-4c8e-9a77-1d2f5b6c8e10",
"external_user_id": "customer-user-4471",
"identity_expires": 1756280000,
"identity_signature": "902ddd4e1a5c5987ce06e0c4dace6a841755a6a185b57d6fbe8631575cffe933",
"plugin_version": "1.0.0"
}200
{
"message": "Plugin session issued",
"result": {
"token": "mep_Xn7Qv2LpR8sT4wY6bK1mJ9zC3hD5fA0gE2uV8iO4rN6qS1tW",
"token_type": "Bearer",
"expires_in": 3600,
"expires_at": "2026-08-13T10:14:22+00:00",
"mode": "b",
"tier": "production",
"origin": "https://app.customer.com",
"plugin": {
"plugin_id": "9f3c1e7a-4b21-4c8e-9a77-1d2f5b6c8e10",
"name": "Acme Editor",
"version": "1.0.0"
}
}
}| Field | Notes |
|---|---|
token | Opaque. Shown once |
expires_in | Seconds. Call this endpoint again on expiry; there is no refresh token |
mode | a = secret exchange, b = origin-based |
tier | production when the domain is verified, otherwise development |
origin | The origin the session is restricted to, or null for a mode A token issued without origin |
Signing the identity
Without a signature the browser chooses external_user_id freely. The signature proves the identity without putting the secret in the page.
Sign on your server while rendering the page that hosts the editor:
v1|{byte length of external_user_id}:{external_user_id}|{identity_expires}For customer-user-4471 expiring at 1756280000 the payload is v1|18:customer-user-4471|1756280000; signed with the secret 8f2b91c0d4e64a7fa3c5b1d7e9028461 it gives the signature in the request above.
identity_expires may be at most 15 minutes ahead. Count the id's length in bytes, not characters. Snippets for Node, PHP, Python and Ruby are on the identity signing page.
From your server (mode A)
Send no Origin header. Add origin whenever the token will be handed to a browser, so it cannot be used anywhere else.
{
"plugin_id": "9f3c1e7a-4b21-4c8e-9a77-1d2f5b6c8e10",
"plugin_secret": "8f2b91c0d4e64a7fa3c5b1d7e9028461",
"origin": "https://app.customer.com",
"external_user_id": "customer-user-4471"
}200 has the same shape with "mode": "a". When origin is omitted, result.origin is null and the token works from any origin. Mode A ignores identity_mode: the secret is the proof.
Errors
422 when a secret was sent from a browser, whether or not it is correct.
{
"message": "A plugin secret must never be sent from a browser: it would be readable by every visitor to your page. Exchange it for a token on your server, or omit it entirely to use origin-based sessions.",
"errors": { "plugin_secret": ["A plugin secret must never be sent from a browser: …"] }
}403 origin_not_allowed for a MailEditor-Origin header or an origin field that is not on the allowlist.
{
"message": "This origin is not allowed for this plugin",
"result": { "error": "origin_not_allowed", "origin": "https://staging.customer.com" }
}403 origin_required when neither a secret nor a usable origin arrived, or MailEditor-Origin is missing.
{
"message": "A valid Origin header is required",
"result": { "error": "origin_required", "hint": "Browsers set this automatically. Calling from a server? Send plugin_secret instead." }
}402 quota_exceeded when external_user_id is new to the plugin and the plan's users allowance is spent. A user who already has saved work is never refused, and an anonymous session takes no seat.
{
"message": "This plugin has used its users allowance of 25 for this period",
"result": { "error": "quota_exceeded", "feature": "users", "limit": "25" }
}401 invalid_plugin for an unknown plugin, an inactive plugin, or a wrong secret.
{ "message": "Invalid plugin credentials", "result": { "error": "invalid_plugin" } }422 origin_invalid when the origin field is malformed.
401 identity_signature_invalid when the signature does not match: the id was edited, the payload was built differently, or the secret is wrong.
{
"message": "The identity signature is not valid for this plugin",
"result": { "error": "identity_signature_invalid", "hint": "Sign external_user_id and identity_expires on your server with the plugin secret." }
}401 identity_signature_expired when identity_expires is in the past. A few seconds of clock drift are tolerated.
422 identity_expires_out_of_range when identity_expires is more than 15 minutes ahead. The response carries max_lifetime_seconds: 900.
403 identity_signature_required when the plugin's identity_mode is signed and no signature arrived.
{
"message": "This plugin requires a signed identity on browser sessions",
"result": { "error": "identity_signature_required", "hint": "Send external_user_id, identity_expires and identity_signature, or set the plugin's identity_mode to open." }
}403 browser_sessions_disabled when the plugin's identity_mode is backend.
422 when external_user_id begins with the reserved anon: prefix.
429 when rate limited: 10 per minute per plugin and IP, 100 per hour and 1000 per day per plugin. Carries Retry-After.
Revoke a session token
The editor calls this when the iframe is removed. The token stops working immediately.
DELETE /public/plugin/v1/session
Origin: https://maileditor.net
MailEditor-Origin: https://app.customer.com
Authorization: Bearer mep_Xn7Qv2LpR8sT4wY6bK1mJ9zC3hD5fA0gE2uV8iO4rN6qS1tW200
{ "message": "Plugin session revoked", "result": null }403 origin_mismatch when the token is used from an origin other than the one the session was opened from. This is checked on every authenticated request. A mode A token issued without origin skips the check.
{
"message": "Origin does not match this session",
"result": { "error": "origin_mismatch", "origin": "https://evil.example" }
}401 invalid_token for an expired, revoked or unknown token.
{ "message": "Invalid or expired plugin session", "result": { "error": "invalid_token" } }
.png)