# Destinations

> Have Mynth write finished images into your own S3, R2 or Bunny storage and return a URL on your domain. Setup, path templates, and how a failed upload shows up.

A destination is storage of yours that Mynth writes finished files into. Name
one on a request and Mynth uploads each image there before the task
completes. `url` on the image is then your URL, and `mynth_url` is still
Mynth's own copy.

Without a destination, the file is served from `mynth_url` for 7 days. Add a
destination when the file has to last longer or live on your own domain.

```ts
import Mynth from "@mynthio/sdk";

const mynth = new Mynth();

const task = await mynth.image.generate({
  model: "black-forest-labs/flux.2-pro",
  prompt: "A lighthouse at dusk, film grain",
  destination: "prod-cdn",
});

const [image] = task.getImages();
console.log(image?.url); // your storage, or null if the upload failed
console.log(image?.mynth_url); // Mynth's copy, always set
```

## Rules

- `destination` takes the destination's **name**, not its `dst_` id.
- Only `POST /image/generate` and `POST /image/remove-background` accept it.
  `POST /video/generate` drops the field without an error.
- A name you don't own fails at create with `400 VALIDATION_ERROR` and
  `Destination "prod-cdn" not found`. Nothing is queued or held.
- The upload happens inside the task. A task that reports `completed` has
  already finished it.
- A failed upload does not fail the image. The image succeeds with
  `url: null`, and `mynth_url` is set. See [what comes back](#what-comes-back).
- Uploading does not change the price.
- Every `/destinations` endpoint needs a key with the `manage` scope.

## How the upload works

```text
image generated ──▶ Mynth CDN                        mynth_url  (always set)
                      │
                      └─▶ PUT into your storage      url
                            key: path_template + "." + format
                            url: url_template with {path} filled in
                            5 attempts, then url: null
```

Mynth writes its own copy first and streams from there into your storage, so a
destination is a second copy rather than a redirect. Each upload gets 5
attempts of up to 10 minutes each. Deleting a destination later leaves the
files already uploaded in place.

## Providers

| `provider.id` | Config                                                               | `secret`                             |
| ------------- | -------------------------------------------------------------------- | ------------------------------------ |
| `s3`          | `bucket`, `region`, optional `endpoint`, optional `force_path_style` | `access_key_id`, `secret_access_key` |
| `r2`          | `account_id`, `bucket`, optional `jurisdiction`                      | `access_key_id`, `secret_access_key` |
| `bunny`       | `storage_zone`, optional `region`                                    | `password`                           |

Use `s3` for anything that speaks the S3 API. Point `endpoint` at Backblaze B2,
MinIO, Wasabi, or DigitalOcean Spaces, and set `"force_path_style": true` when
the endpoint needs path-style addressing. R2 `jurisdiction` accepts `default`,
`eu`, or `fedramp`. Bunny `region` defaults to `de`.

Mynth keeps `secret` in a vault separate from the destination record, and no
endpoint returns it. `GET /destinations` returns `id`, `name`, `provider`,
`config`, and timestamps.

## Create a destination

**CLI**

```bash
npx @mynthio/cli destination create prod-cdn \
  --provider r2 \
  --account-id 3b1c9f2e7a4d6b8c0e1f3a5d7b9c1e3f \
  --bucket media \
  --path-template 'images/{YYYY}/{MM}/{id}' \
  --url-template 'https://cdn.example.com/{path}' \
  --secret ./r2-secret.json
```

**REST**

```bash
curl https://api.mynth.io/destinations \
  -X POST \
  -H "Authorization: Bearer $MYNTH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "prod-cdn",
    "provider": { "id": "r2", "account_id": "3b1c9f2e7a4d6b8c0e1f3a5d7b9c1e3f", "bucket": "media" },
    "secret": { "access_key_id": "...", "secret_access_key": "..." },
    "config": {
      "path_template": "images/{YYYY}/{MM}/{id}",
      "url_template": "https://cdn.example.com/{path}"
    }
  }'
```

The CLI reads the secret from a file, or from stdin with `--secret -`, so it
stays out of your shell history. For Bunny the file can hold the bare
password instead of JSON. The [dashboard](https://mynth.io/dashboard/destinations) has a form
per provider with a live preview of the resolved path.

`name` is 1 to 64 characters of lowercase letters, digits, and dashes, and it
cannot be changed. `PUT /destinations/{id}` replaces `provider` and `config`,
and takes a new `secret` when you rotate credentials. Changing `provider.id`
fails with `400 DESTINATIONS_INVALID_PROVIDER`, so switching providers means a
new destination.

Mynth caches the destination for 5 minutes and its secret for 10. An edited
template or rotated credentials can take up to 10 minutes to reach new tasks,
so keep the old credentials valid until then.

## Path and URL templates

`path_template` is the object key without the extension. Mynth appends a dot
and the delivered format (`webp`, `png`, or `jpg`) and sets the matching
`Content-Type`. Tokens resolve per file, at upload time:

| Token                                   | Becomes                                                                    |
| --------------------------------------- | -------------------------------------------------------------------------- |
| `{id}`                                  | the Mynth image id, `img_...`                                              |
| `{YYYY}` `{MM}` `{DD}`                  | the upload date                                                            |
| `{ulid}` `{uuid}` `{uuidv4}` `{uuidv7}` | a fresh random id, different for every file                                |
| `{meta.<key>}`                          | that string from the request's `metadata`, or the literal text `undefined` |

`url_template` is what Mynth reports as `url`. It must contain `{path}`. If
you leave it out, the upload still runs, but `url` comes back `null`, which on
`image.generate` looks the same as a failed upload. Set it unless the storage
is private and you only ever read objects by key.

> **Warning**
>
> A template with no per-file token overwrites. `images/{meta.slug}` writes the same key four times
> for a `count: 4` request, and you keep one image. Include `{id}` or `{ulid}` unless overwriting is
> what you want.

## Test the credentials

`POST /destinations/{id}/test` uploads a small probe image to the `path` in
the body and answers `204`. Bad credentials, a wrong region, or a missing
bucket come back as `502 DESTINATION_TEST_FAILED` with the provider's own
message.

```bash
npx @mynthio/cli destination test dst_01KE7XWWEQ4MCGWKBQKJ1G47RP
```

The `path` is used exactly as given: no tokens resolve and no extension is
added. The probe is a WEBP image whatever you name it. Delete it yourself
afterwards.

## Use a destination on every request

Set `MYNTH_DESTINATION`, or pass `new Mynth({ destination: "prod-cdn" })`. The
SDK and the CLI both read the variable, and a `destination` on the request
takes precedence over both.

## What comes back

```json
{
  "status": "success",
  "id": "img_V1StGXR8Z5jdHi6BmyT0sC1pQ2rN4wLk",
  "url": "https://cdn.example.com/images/2026/07/img_V1StGXR8Z5jdHi6BmyT0sC1pQ2rN4wLk.webp",
  "mynth_url": "https://cdn.mynth.io/images/img_V1StGXR8Z5jdHi6BmyT0sC1pQ2rN4wLk.webp",
  "size": "1536x1024",
  "format": "webp"
}
```

`url` is your storage when the upload worked and `null` when it did not.
Store both fields. Render `url`, and fall back to `mynth_url`.

How a failure shows up depends on the task type:

| Task type                 | Upload failed                           | Destination deleted before the upload                            |
| ------------------------- | --------------------------------------- | ---------------------------------------------------------------- |
| `image.generate`          | `url: null`, no reason given            | `url` is the Mynth URL; nothing reaches your storage             |
| `image.remove_background` | `url: null`, plus a `destination` block | `url: null`, `destination.error.code` is `DESTINATION_NOT_FOUND` |

On `image.remove_background`, the image carries a `destination` block:

```json
"destination": { "status": "failed", "name": "prod-cdn", "error": { "code": "UNKNOWN_ERROR" } }
```

Rejected credentials or a wrong bucket currently report `UNKNOWN_ERROR`, so
check your provider's logs for the reason.

> **Warning**
>
> On `image.generate` a failed upload leaves no error in `errors` and no `destination` block: only
> `url: null` next to a populated `mynth_url`. In the SDK, `task.urls` drops those images. Read
> `getImages()` when a destination is in use.

## Errors

| Status | Code                            | When                                                         |
| ------ | ------------------------------- | ------------------------------------------------------------ |
| 400    | `VALIDATION_ERROR`              | A generation request named a destination you don't own       |
| 400    | `DESTINATIONS_INVALID_PROVIDER` | `PUT /destinations/{id}` changed `provider.id`               |
| 404    | `DESTINATION_NOT_FOUND`         | Unknown `dst_` id                                            |
| 409    | `DESTINATION_NAME_TAKEN`        | You already have a destination with that name                |
| 500    | `UNKNOWN_ERROR`                 | Storing or reading the secret failed                         |
| 502    | `DESTINATION_TEST_FAILED`       | Your storage rejected the probe. `message` is the provider's |

## Next steps

- [Tasks](https://mynth.io/docs/concepts/tasks.md#file-lifetimes): how long `mynth_url` lasts.
- [Webhooks](https://mynth.io/docs/concepts/webhooks.md): learn when the task, and its upload, is done.
- [Create destination](https://mynth.io/docs/api-reference/endpoints/destinations/create.md): the full request schema.
