Create a template

POST https://emails.routeon.io/api/v1/templates

Parameters

Idempotency-Key string header

Replays with the same key return the original result. **Key shape.** A non-empty string of at most 255 characters after trimming surrounding whitespace. An empty or oversized key is rejected with 422 `VALIDATION_ERROR` (`idempotency_key_empty` / `idempotency_key_too_long`) rather than silently running the request unprotected. Leading/trailing whitespace is trimmed, so `" k "` and `"k"` are the same key. **Detecting a replay.** A response served from the idempotency cache rather than by re-executing the operation carries the response header `Idempotency-Replayed: true`; a freshly executed response omits the header entirely. **Replay window.** A record is retained for 24 h by default, after which the same key is free again and the operation re-executes. Only a 2xx outcome is recorded — a request that failed can be retried with the same key. **Conflicts.** Reusing a key with a different request is `409 idempotency_key_conflict`. The request identity is the method, path, query string and body, except on `POST /transactional/send`, where only `domain_id` + `to` (case-insensitive) + `template_id` + `variables` + `send_at` are compared (the parsed instant normalised to ISO-8601, and omitted entirely rather than sent as `null` when the request carried no `send_at`); a replay of that operation does **not** carry `Idempotency-Replayed`. **Concurrency.** A key is claimed before the operation executes, so two requests carrying the same key can never both run. The second one is rejected with `409 idempotency_in_progress` while the first is still in flight. This is a *transient* conflict — distinct from `idempotency_key_conflict` above, which is permanent — so retry after a short delay: once the first request completes, the same key replays its result. A claim is released as soon as the first request fails or returns a non-2xx, and expires after 5 min if the process handling it dies, so a key can never be locked indefinitely. **Rate limiting.** A replay short-circuits before the rate limiter, so it consumes no quota and returns no `X-RateLimit-*` headers. This is deliberate: a client retrying after a timeout must not be penalised for work that was already done. **Scope.** Keys are scoped per tenant.

Request body

{
    "name": "string",
    "document": {
        "schema_version": 1,
        "default_locale": "string",
        "locales": [
            "string"
        ],
        "brand_kit_id": "00000000-0000-0000-0000-000000000000",
        "subject": [],
        "preheader": [],
        "blocks": [
            {
                "id": "string",
                "type": "row",
                "children": [
                    {
                        "id": "string",
                        "type": "row",
                        "children": [
                            null
                        ],
                        "locked": true,
                        "locked_editable_props": [
                            null
                        ],
                        "props": {
                            "content": null,
                            "style": null,
                            "format": null
                        }
                    }
                ],
                "locked": true,
                "locked_editable_props": [
                    "string"
                ],
                "props": {
                    "content": [],
                    "style": [],
                    "format": "mjml"
                }
            }
        ]
    },
    "format": "mjml",
    "source": "string",
    "subject": "string",
    "preheader": "string",
    "message_class": "marketing"
}

Responses

201 OK
422 Body/query/params failed validation. meta.reason=VALIDATION_ERROR, meta.validation_error[] carries {field, message} per failure ('' field = show the message as-is). Always 422 — a request that parsed but broke a rule is never 400. Sender resolution reports through meta.details.code: invalid_from_address (the `from` string is not `local@domain` or `Name <local@domain>`), from_domain_mismatch (its domain is not a verified domain of the tenant), domain_required (neither `domain_id` nor `from` was given), sender_not_configured (no route and no default profile resolves for this send) and sender_not_found (the referenced profile id is unknown to the tenant). content_moderation_rejected: POST /transactional/send and /transactional/send-batch verify the template's active version before accepting the send, and a rejected verdict fails the request. The check cannot be switched off.

Example response · 201

{
    "status": "success",
    "http_code": 0,
    "payload": {
        "id": "00000000-0000-0000-0000-000000000000",
        "tenant_id": "00000000-0000-0000-0000-000000000000",
        "name": "string",
        "current_version": 0,
        "status": "draft",
        "strict_variables": true,
        "message_class": "marketing",
        "created_at": "2024-01-01T00:00:00Z",
        "updated_at": "2024-01-01T00:00:00Z"
    },
    "meta": []
}

Example response · 422

{
    "status": "error",
    "http_code": 0,
    "payload": [],
    "meta": {
        "reason": "VALIDATION_ERROR",
        "validation_error": [
            {
                "field": "string",
                "message": "string"
            }
        ],
        "details": []
    }
}