Skip to content

Getting started ​

Get an API key, create a template and render it through the Postcraft API.

1. Get an API key ​

Sign up or sign in, then open Settings in the dashboard and select API keys & agents. Give the key a label and click Create key.

Copy the key from the reveal dialog. Postcraft hashes keys on the server, so this is the only time you will see the full value. Treat it like a password.

2. Create your first template ​

A template is either visual blocks, the editor's default, or raw Handlebars in code mode. This call creates a code-mode template. The snippets use Node 22+ fetch, Python httpx, PHP Guzzle, and Go's standard library:

bash
export API_KEY=<your-key>

curl -X POST https://postcraft.io/api/v1/templates \
  -H "x-api-key: $API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "name": "welcome",
    "code": "Hello {{name}}, welcome to {{product}}!",
    "exampleContext": { "name": "Ada", "product": "Postcraft" }
  }'
js
const res = await fetch("https://postcraft.io/api/v1/templates", {
  method: "POST",
  headers: {
    "x-api-key": process.env.API_KEY,
    "content-type": "application/json",
  },
  body: JSON.stringify({
    name: "welcome",
    code: "Hello {{name}}, welcome to {{product}}!",
    exampleContext: { name: "Ada", product: "Postcraft" },
  }),
});

const { template } = await res.json();
console.log(template.id); // e.g. "bonipyZlnbwtYVSzQO3AA"
python
import os, httpx

res = httpx.post(
    "https://postcraft.io/api/v1/templates",
    headers={"x-api-key": os.environ["API_KEY"]},
    json={
        "name": "welcome",
        "code": "Hello {{name}}, welcome to {{product}}!",
        "exampleContext": {"name": "Ada", "product": "Postcraft"},
    },
)

template = res.json()["template"]
print(template["id"])  # e.g. "bonipyZlnbwtYVSzQO3AA"
php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client();

$res = $client->post('https://postcraft.io/api/v1/templates', [
    'headers' => ['x-api-key' => getenv('API_KEY')],
    'json' => [
        'name' => 'welcome',
        'code' => 'Hello {{name}}, welcome to {{product}}!',
        'exampleContext' => ['name' => 'Ada', 'product' => 'Postcraft'],
    ],
]);

$template = json_decode((string) $res->getBody(), true)['template'];
echo $template['id']; // e.g. "bonipyZlnbwtYVSzQO3AA"
go
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

func main() {
	body, _ := json.Marshal(map[string]any{
		"name":           "welcome",
		"code":           "Hello {{name}}, welcome to {{product}}!",
		"exampleContext": map[string]string{"name": "Ada", "product": "Postcraft"},
	})

	req, _ := http.NewRequest("POST", "https://postcraft.io/api/v1/templates", bytes.NewReader(body))
	req.Header.Set("x-api-key", os.Getenv("API_KEY"))
	req.Header.Set("content-type", "application/json")

	res, _ := http.DefaultClient.Do(req)
	defer res.Body.Close()

	var out struct {
		Template struct {
			ID string `json:"id"`
		} `json:"template"`
	}
	json.NewDecoder(res.Body).Decode(&out)
	fmt.Println(out.Template.ID) // e.g. "bonipyZlnbwtYVSzQO3AA"
}

The response carries the new template's metadata, plus folder echoing where it was filed (null at the root) and diagnostics listing any problems found in the template:

json
{
  "template": {
    "id": "bonipyZlnbwtYVSzQO3AA",
    "name": "welcome",
    "createdAt": "…",
    "updatedAt": "…",
    "publishedAt": "…",
    "hasDraftChanges": false,
    "createdBy": "…",
    "folderId": null
  },
  "folder": null,
  "diagnostics": []
}

Move it later ​

Pass folder on an update to move a template after creation. Same semantics as create: missing folders along the path are created, and null unfiles the template back to the root. Folder moves require an API key (presigned editor tokens can't move templates).

bash
curl -X PUT https://postcraft.io/api/v1/templates/<id> \
  -H "x-api-key: $API_KEY" \
  -H "content-type: application/json" \
  -d '{ "folder": "marketing/onboarding" }'

GET /templates/<id> returns the current location as folder: { id, path } (null at the root).

3. Render against runtime data ​

Render takes the runtime context in the body and returns one self-contained HTML string. Pick a target:

  • browser (default): modern HTML with a <style> block.
  • email: table-based shell with inline styles and MSO conditional comments.
bash
curl -X POST https://postcraft.io/api/v1/templates/<id>/render \
  -H "x-api-key: $API_KEY" \
  -H "content-type: application/json" \
  -d '{ "context": { "name": "Ada", "product": "Postcraft" }, "target": "email" }'
js
const res = await fetch(
  `https://postcraft.io/api/v1/templates/${templateId}/render`,
  {
    method: "POST",
    headers: {
      "x-api-key": process.env.API_KEY,
      "content-type": "application/json",
    },
    body: JSON.stringify({
      context: { name: "Ada", product: "Postcraft" },
      target: "email",
    }),
  },
);

const { html, text } = await res.json();
python
import os, httpx

res = httpx.post(
    f"https://postcraft.io/api/v1/templates/{template_id}/render",
    headers={"x-api-key": os.environ["API_KEY"]},
    json={
        "context": {"name": "Ada", "product": "Postcraft"},
        "target": "email",
    },
)

data = res.json()
html, text = data["html"], data["text"]
php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client();

$res = $client->post("https://postcraft.io/api/v1/templates/{$templateId}/render", [
    'headers' => ['x-api-key' => getenv('API_KEY')],
    'json' => [
        'context' => ['name' => 'Ada', 'product' => 'Postcraft'],
        'target' => 'email',
    ],
]);

$data = json_decode((string) $res->getBody(), true);
$html = $data['html'];
$text = $data['text'];
go
package main

import (
	"bytes"
	"encoding/json"
	"net/http"
	"os"
)

func main() {
	body, _ := json.Marshal(map[string]any{
		"context": map[string]string{"name": "Ada", "product": "Postcraft"},
		"target":  "email",
	})

	url := "https://postcraft.io/api/v1/templates/" + templateID + "/render"
	req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
	req.Header.Set("x-api-key", os.Getenv("API_KEY"))
	req.Header.Set("content-type", "application/json")

	res, _ := http.DefaultClient.Do(req)
	defer res.Body.Close()

	var out struct {
		HTML string `json:"html"`
		Text string `json:"text"`
	}
	json.NewDecoder(res.Body).Decode(&out)
}

Send the body as content-type: application/json. Any other media type is refused with invalid_input rather than ignored, because every field here is optional: a body that went unread would render successfully with no context at all and quietly drop your personalization.

When target is email, the response also carries a text field: a plain-text alternative generated from the rendered email, ready to drop into the text part of a multipart message. (It is omitted for browser renders.)

Every render also returns the template's subject, rendered against the same context, or null when the template has none or it renders to nothing. It comes back safe to use as a single header line: every run of line breaks, other whitespace or control characters becomes one space, the ends are trimmed, and it is cut to 998 characters. A subject is optional. Set it on create or update as Handlebars, and send null to remove it. The API reference covers setting and clearing a subject.

json
{
  "html": "<!doctype html><html …>Hello Ada, welcome to Postcraft!</html>",
  "text": "Hello Ada, welcome to Postcraft!",
  "subject": "Welcome to Postcraft, Ada"
}

4. Hand the editor to a teammate ​

To let someone edit or view a single template without sharing your API key, mint a presigned URL:

bash
curl https://postcraft.io/api/v1/templates/<id>/presigned-url \
  -H "x-api-key: $API_KEY"
json
{
  "url": "https://postcraft.io/edit?templateId=bonipyZlnbwtYVSzQO3AA&readonly=false&token=…"
}

The URL is good for four hours. Add ?readonly=true to mint a view-only link.

Drafts and publishing ​

A template carries two copies of its content: the published one, which is what /render serves, and an optional draft.

PUT /templates/{id} writes the published copy, so an integration that has never heard of drafts keeps deploying on write. Pass "stage": "draft" in the body to stage a change instead, and POST /templates/{id}/publish to promote it:

bash
curl -X PUT https://postcraft.io/api/v1/templates/<id> \
  -H "x-api-key: $API_KEY" -H "content-type: application/json" \
  -d '{ "code": "Hello {{firstName}}, new copy", "stage": "draft" }'

# What /render serves has not changed yet.
curl -X POST https://postcraft.io/api/v1/templates/<id>/publish \
  -H "x-api-key: $API_KEY"

GET /templates/{id} reads the published copy; add ?stage=draft to read work in progress. Every read carries hasDraftChanges, and publishedAt tells you when what renders last changed. DELETE /templates/{id}/draft throws unpublished changes away.

The visual editor and Postcraft's agent tools always write the draft, so an author or an AI agent can work on a template that is live without changing a send in flight.

Authentication recap ​

Every API endpoint accepts one of:

  • x-api-key: <key>: long-lived credential for a workspace. Scopes the caller to that workspace's templates.
  • Authorization: Bearer <token>: short-lived, single-template presigned token (minted via the /presigned-url endpoint).

Use the API key for server-to-server calls. Use a presigned token when you're handing the editor to someone else.

Errors ​

Every non-2xx response shares one envelope so callers can branch programmatically:

json
{ "error": { "code": "not_found", "message": "Template not found" } }

code is stable (not_found, unauthorized, forbidden, invalid_input, conflict, render_failed, payload_too_large, rate_limited, internal_error); message is prose for logs.

Next ​