The `sendops.json` manifest file is an optional configuration file you place in your repository root. It gives you explicit control over which files are synced as templates, what SES template names they map to, and where test data profiles are stored.

## With vs without a manifest

**Without `sendops.json`** — SendOps scans the configured template path for all `.html`, `.hbs`, and `.handlebars` files. Each file becomes a template named after the file (without extension). Test data profiles cannot be synced from the repository — you can only create them manually in SendOps.

**With `sendops.json`** — SendOps reads the manifest instead of scanning the directory. Only templates listed in the manifest are synced. You control the template name independently of the file name, and you can define test data profiles that sync automatically from your repository.


  If `sendops.json` exists but has fatal errors (invalid JSON, missing `templates` key, empty template map), SendOps logs a warning and falls back to directory scanning. Check the sync history for warnings if templates aren't appearing as expected.


## File location and limits

- **Location**: at the root of your configured **Subfolder** (or the repository root if you didn't set a Subfolder) — not inside the Template Path
- **Maximum size**: 64 KB


  If your GitHub connection sets a **Subfolder** (the primary path field), SendOps reads `sendops.json` from `<subfolder>/sendops.json`, and **every path inside the manifest** — template `path`, `testdata`, `images` — is resolved **relative to that subfolder**, not the repository root. Leave the Subfolder blank and paths are relative to the repo root.


## Format

```json
{
  "templates": {
    "welcome-email": {
      "path": "emails/welcome.html",
      "testdata": "fixtures/welcome/",
      "subject": "Welcome to {{productName}}",
      "default_consent_class": "transactional"
    },
    "order-confirmation": {
      "path": "emails/order-confirm.hbs",
      "testdata": "fixtures/order-confirm.json"
    },
    "password-reset": {
      "path": "emails/password-reset.html"
    }
  }
}
```

### Fields


  <Field name="templates" type="map" required>
    A map of template entries. Each key is the **template name** that will be used in SES. Must contain at least one entry.

    The key can be any string — it doesn't need to match the file name. This is how you control the SES template name independently of your file structure.
  </Field>

  <Field name="path" type="string" required scope="per template">
    The repository-relative path to the template file.

    - Must not be empty
    - Must not be an absolute path (no leading `/`)
    - Must not contain directory traversal (`../`)

    If any of these rules are violated, the entry is **skipped** and a warning is logged.
  </Field>

  <Field name="testdata" type="string" scope="per template">
    The repository-relative path to test data for this template. Can point to either:

    - **A directory** (path ends with `/`) — all `.json` files in the directory are loaded as test data profiles
    - **A single file** (path ends with `.json`) — the file is loaded as one or more test data profiles

    If omitted, the template syncs normally but no git-synced test data profiles are created. If the path has an invalid extension or contains directory traversal, a warning is logged and test data is skipped for that template.
  </Field>

  <Field name="subject" type="string" scope="per template">
    The subject line SendOps sets on the deployed SES template. It may contain Handlebars expressions (for example `Welcome to {{productName}}`) that SES fills in from your send-time data. If omitted, SES falls back to using the template name as the subject — rarely what you want, so set this for any template you actually send.
  </Field>

  <Field name="default_topic" type="string" scope="per template">
    The name of the subscription **topic** a send of this template opts recipients out of by default. A [Drip Workflow](/workflows/overview) `send "<template>"` step that doesn't name its own topic inherits this one. The topic is resolved by name at send time, so it may reference a topic you haven't created yet.
  </Field>

  <Field name="default_consent_class" type="string" scope="per template">
    Which consent lane the template's sends travel in. Only two values are valid: `""` (empty — the default, **marketing**) or `"transactional"` (topic-exempt lifecycle mail such as receipts and password resets). **Any other value is dropped with a warning** and the template falls back to marketing. When set to `transactional`, `default_topic` is ignored. See [Consent & lifecycle](/sending-email/consent-and-lifecycle) for how the two lanes differ.
  </Field>


## Template naming

The **key** in the `templates` map becomes the SES template name. This lets you decouple the file name from the template name:

```json
{
  "templates": {
    "transactional-welcome": {
      "path": "src/emails/welcome-v2.hbs"
    }
  }
}
```

Here the file is `welcome-v2.hbs` but the SES template is named `transactional-welcome`.

Without a manifest, the SES template name is always derived from the file name (minus extension), so `welcome-v2.hbs` would create a template named `welcome-v2`.

## Images

The optional top-level `images` key lists the repository images SendOps should promote to your [Edge CDN](/edge/edge-stack) so templates can reference them. Each entry is a repository-relative (or path-prefix-relative) path:

```json
{
  "templates": {
    "welcome": { "path": "templates/welcome.html" }
  },
  "images": [
    "static/logo.png",
    "static/social/twitter.png"
  ]
}
```


  When `sendops.json` is present, SendOps promotes **only** the images in the `images` array. An image that a template references but that isn't listed here will not be uploaded. Without a manifest, SendOps instead scans your path prefix and promotes every supported image it finds.


Promoted images are served from your Edge CDN and referenced in templates via the `asset` helper or a repository-relative `src`. See [Asset Library & Images](/edge/asset-library) for the full workflow, supported file types, and how references are rewritten on deploy.

## Test data profiles

Test data profiles defined in the manifest are synced as **git-synced profiles** — they appear in SendOps as read-only and update automatically on each sync. See [Previews & Test Data](/templates/template-versioning) for how profiles work in the UI.

### Single file format

A test data file can be either a JSON **object** or a JSON **array**.

**Object** — creates one profile named after the file:

```json title="fixtures/welcome.json"
{
  "name": "Jane Smith",
  "premium_member": true,
  "signup_date": "2025-01-15"
}
```

This creates a profile named `welcome` (from the file name `welcome.json`).

**Array** — creates multiple profiles from a single file. Each element must include a `_name` field:

```json title="fixtures/welcome.json"
[
  {
    "_name": "Premium user",
    "name": "Jane Smith",
    "premium_member": true
  },
  {
    "_name": "Free user",
    "name": "John Doe",
    "premium_member": false
  }
]
```

This creates two profiles: "Premium user" and "Free user". The `_name` field is stripped from the stored profile data — it's only used to name the profile.

### Directory format

When `testdata` points to a directory, every `.json` file in that directory is loaded using the same rules as single files (object or array). This lets you organize test data into separate files:


```
fixtures/
└── welcome/
    ├── premium-user.json      → profile "premium-user"
    ├── free-user.json         → profile "free-user"
    └── edge-cases.json        → array with multiple profiles
```


Each file is limited to 64 KB.

### Profile lifecycle

On each sync, SendOps:

1. Upserts all profiles from the manifest (creating new ones or updating existing ones based on content hash)
2. Deletes any git-synced profiles that are no longer in the manifest

This means:
- **Renaming** a test data file creates a new profile and deletes the old one
- **Removing** the `testdata` field from a manifest entry deletes all git-synced profiles for that template
- **Removing** all files from a test data directory deletes all git-synced profiles for that template
- **User-managed profiles** (created in the SendOps UI) are never affected by manifest syncs

## Other recognized top-level keys

Beyond `templates` and `images`, SendOps recognizes several other top-level keys that declare **git-backed audience definitions**: `segments`, `workflows`, `attributes`, `activity-properties`, `lists`, and `topics`. These let you manage segments, drip workflows, contact attributes, and subscription topics as code in the same `sendops.json`. They're handled by SendOps's definitions sync (see the audience and workflow docs), not the template sync, so they do **not** trigger the unknown-field warning below.

## Unknown fields

Any *other* top-level field in `sendops.json` is ignored with a warning. This means you can add comments or metadata fields without breaking the sync, but you'll see warnings in the sync history:

```json
{
  "version": 1,
  "templates": { ... }
}
```

This logs: `unknown top-level field "version" ignored`.

## Validation errors

### Fatal errors

Sync falls back to directory scan

| Error | Cause |
|-------|-------|
| File too large | `sendops.json` exceeds 64 KB |
| Invalid JSON | Syntax error in the file |
| Missing `templates` | The `templates` key is not present |
| Empty template map | `templates` is present but has no entries |

### Warnings

Individual entries skipped or adjusted

| Warning | Cause | Effect |
|---------|-------|--------|
| Empty template name | A key in `templates` is an empty string | Entry skipped |
| Empty path | `path` field is missing or empty | Entry skipped |
| Absolute path | `path` starts with `/` | Entry skipped |
| Path traversal | `path` contains `../` | Entry skipped |
| Invalid testdata extension | `testdata` doesn't end with `/` or `.json` | Template syncs, test data skipped |
| Testdata path traversal | `testdata` contains `../` | Template syncs, test data skipped |

Warnings appear in the sync history on the Templates page.

## Example: full manifest

```json
{
  "templates": {
    "welcome": {
      "path": "templates/welcome.hbs",
      "testdata": "testdata/welcome/"
    },
    "order-confirmation": {
      "path": "templates/order-confirmation.hbs",
      "testdata": "testdata/order-confirmation.json"
    },
    "password-reset": {
      "path": "templates/password-reset.html",
      "testdata": "testdata/password-reset/"
    },
    "weekly-digest": {
      "path": "templates/marketing/weekly-digest.hbs"
    }
  }
}
```

With a matching directory structure:


```
repo/
├── sendops.json
├── templates/
│   ├── welcome.hbs
│   ├── order-confirmation.hbs
│   ├── password-reset.html
│   └── marketing/
│       └── weekly-digest.hbs
└── testdata/
    ├── welcome/
    │   ├── new-user.json
    │   └── returning-user.json
    ├── order-confirmation.json
    └── password-reset/
        └── standard.json
```


## What's next?

- [Template Management](/templates/template-management) — connecting GitHub, sync, validation, and Handlebars syntax.
- [Previews & Test Data](/templates/template-versioning) — using test data profiles for previews and test emails.