# Getting started

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

## 1. Get an API key

[Sign up](https://postcraft.io/dashboard/register) or [sign in](https://postcraft.io/dashboard/login), 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:

**cURL**

```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" }
  }'
```

**JavaScript**

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

```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
<?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**

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

**cURL**

```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" }'
```

**JavaScript**

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

```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
<?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**

```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](https://postcraft.io/docs/reference/api.md#subject-line).

```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](https://postcraft.io/docs/reference/ai.md) 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](https://postcraft.io/docs/workspaces.md). 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

- [**API reference**](https://postcraft.io/docs/reference/api.md): every operation, every field.
- [**Handlebars helpers**](https://postcraft.io/docs/template-helpers.md): the helpers Postcraft ships on top of the standard library.
