# Endpoint profiles

> Create a reusable destination (URL, method, headers, retry/breaker/budget policy) once, then reference it from many schedules by endpoint_id.

An **endpoint profile** is a named, reusable destination plus its delivery policy. Instead
of repeating the same URL, headers, and retry settings on every schedule, you define them
once as a profile (`ep_…`) and reference it by `endpoint_id`. Edit the profile and future
deliveries pick up the change automatically — without rewriting any schedule.

Use a profile when more than one schedule targets the same destination, or when you want a
single place to rotate auth headers and tune retry/breaker behavior.

```bash
# Create a profile once...
curl -sS https://api.schedstack.com/v1/endpoints \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/billing",
    "method": "POST",
    "headers": { "X-Api-Key": "whk_abc123" }
  }'
```

```json
{
  "id": "ep_3Qa1bC2dEf",
  "object": "endpoint",
  "mode": "test",
  "url": "https://example.com/hooks/billing",
  "method": "POST",
  "timeout": null,
  "header_keys": ["X-Api-Key"],
  "metadata": {},
  "version": 1,
  "archived": false,
  "created_at": "2026-06-27T12:00:00Z",
  "updated_at": "2026-06-27T12:00:00Z"
}
```

The response returns `header_keys` (the **names** only), never the header values — values
may be secrets. The values are stored and sent on delivery; they are just never echoed
back.

## Create a profile

`POST /v1/endpoints`. Only `url` is required, and it must be an `https` URL.

| Field | Required | Notes |
|---|---|---|
| `url` | yes | `https` only. A non-`https` URL returns `422 url_blocked`; an absent one returns `422 missing_url`. The URL is not pre-trusted — the resolved IP is re-validated at connect time on every delivery. |
| `method` | no | Defaults to `POST`. An unsupported method returns `400 invalid_method`. |
| `headers` | no | Static auth/custom headers (object of string→string). |
| `retry_policy` | no | Same shape and bounds as a schedule's retry policy (`max_attempts` + backoff). |
| `retry_budget` | no | `{ "rate": <number>, "burst": <number> }`. Caps retry amplification against this destination. Both values must be ≥ 0. |
| `breaker_policy` | no | Circuit-breaker tuning. See [Breaker policy](#breaker-policy) — all four fields are required together. |
| `timeout` | no | Per-request HTTP timeout (duration string up to `1h`). **Stored but not yet enforced** — see below. |
| `rate_limit` | no | `{ "per_second": <number> }`, ≥ 0. **Stored but not yet enforced** — see below. |
| `metadata` | no | Free-form string→string map for your own bookkeeping. |

`timeout` and `rate_limit` are accepted and validated, and they round-trip on the profile,
but the delivery path does **not** apply them yet. Deliveries currently use a fixed egress
timeout and are not throttled by a profile's `rate_limit`. Don't rely on either to bound or
pace delivery today — omit them unless you're pre-populating for a future release.

### Breaker policy

When you set `breaker_policy`, **all four fields are required** — an omitted (zero) open or
probe window produces a breaker that re-opens immediately, i.e. no protection, so the API
rejects a partial policy with `422 invalid_breaker_policy`.

| Field | Notes |
|---|---|
| `threshold` | Consecutive failures before the circuit opens. Integer, 1–1000. |
| `base_open` | Initial open duration. Positive duration string, up to `24h`. |
| `max_open` | Cap on the (backing-off) open duration. Up to `24h`. |
| `probe_timeout` | How long a half-open probe may run before it counts as a failure. Up to `24h`. |

```bash
curl -sS https://api.schedstack.com/v1/endpoints \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/billing",
    "method": "POST",
    "headers": { "X-Api-Key": "whk_abc123" },
    "retry_policy": { "max_attempts": 6 },
    "retry_budget": { "rate": 10, "burst": 100 },
    "breaker_policy": {
      "threshold": 20,
      "base_open": "30s",
      "max_open": "10m",
      "probe_timeout": "5s"
    },
    "metadata": { "team": "billing" }
  }'
```

Reserved delivery headers — the request signature, its timestamp, and the delivery
`Idempotency-Key` — are always set by SchedStack and **overwrite** any same-named header in
your `headers`. See [Idempotency](/docs/concepts/idempotency/) for how your handler dedups
retried deliveries with that key.

## Reference a profile from a schedule

On `POST /v1/schedules`, pass `endpoint_id` **instead of** the inline `endpoint`. The two
are mutually exclusive.

```bash
curl -sS https://api.schedstack.com/v1/schedules \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint_id": "ep_3Qa1bC2dEf",
    "delay": "1h",
    "body": { "invoice": "inv_123" }
  }'
```

The schedule inherits the profile's URL, method, headers, and policies. You still set the
per-schedule fields — timing (`delay` / `fire_at` / `cron`), `body`, `ttl`,
`idempotency_key`, `metadata` — on the schedule itself.

Transport lives on the profile. When you use `endpoint_id`, do **not** also send `method` —
that returns `400 method_on_profile`. Other guardrails on the reference:

- Sending both `endpoint_id` and `endpoint` → `400 multiple_endpoint`.
- An unknown profile → `422 invalid_endpoint`.
- An archived profile → `422 endpoint_archived` (archived profiles can't back new schedules).

## Live-link and freeze-per-delivery

A profile is a **live link** for *future* deliveries but a **frozen snapshot** *within* a
delivery. This is what makes editing a profile safe.

- Every mutating edit (`PATCH`) **bumps the profile's `version`**.
- When a delivery is materialized, SchedStack resolves the profile and **snapshots the
  version it resolved** onto that delivery. New occurrences re-resolve, so a profile edit
  (new header, rotated auth, tuned retry) automatically reaches **future** deliveries.
- That snapshot is **frozen for the life of the delivery, including every retry.** A single
  logical delivery never switches destination mid-retry after a profile edit — its retries
  hit the same URL with the same resolved config it started with.

This keeps receiver dedup correct (a stable `Idempotency-Key` always lands on one
destination) and keeps history auditable: you can ask which URL and profile version an
attempt actually used, and the answer never changes.

There's a small, honest propagation lag: an edit made between an occurrence's
materialization and its fire does **not** reach that already-materialized occurrence. For
recurring schedules that window is roughly one interval; for a one-shot, it's the
create-to-fire gap.

## Update a profile

`PATCH /v1/endpoints/{id}` updates any subset of fields and **bumps `version`**. Future
deliveries resolve against the new version; in-flight deliveries keep their frozen
snapshot.

```bash
curl -sS -X PATCH https://api.schedstack.com/v1/endpoints/ep_3Qa1bC2dEf \
  -H "Authorization: Bearer sk_test_…" \
  -H "Content-Type: application/json" \
  -d '{ "headers": { "X-Api-Key": "whk_rotated456" } }'
```

The same validation as create applies (`https`-only URL, supported method, full
`breaker_policy` if present, `retry_budget` / `rate_limit` ≥ 0).

## Read and list profiles

1. Fetch one:

   ```bash
   curl -sS https://api.schedstack.com/v1/endpoints/ep_3Qa1bC2dEf \
     -H "Authorization: Bearer sk_test_…"
   ```

2. List them (cursor-paginated). Archived profiles are excluded unless you ask for them:

   ```bash
   curl -sS "https://api.schedstack.com/v1/endpoints?include_archived=true" \
     -H "Authorization: Bearer sk_test_…"
   ```

Profiles are scoped to `(project, mode)`, like everything else: an `sk_test_…` key only
sees `test` profiles, and a schedule can only reference a profile in its own mode. See
[Test vs live modes](/docs/concepts/modes/).

## Archive a profile

There is no hard delete — history rows reference profiles forever. Instead, soft-archive:

```bash
curl -sS -X POST https://api.schedstack.com/v1/endpoints/ep_3Qa1bC2dEf/archive \
  -H "Authorization: Bearer sk_test_…"
```

An archived profile stays **queryable** for history and audit, but **cannot back new
schedules**.

If a schedule still references the profile, archiving returns **`409 profile_in_use`**:

```json
{
  "type": "invalid_request_error",
  "code": "profile_in_use",
  "message": "Endpoint profile still backs active schedules; migrate/cancel/pause them first."
}
```

Migrate, cancel, or pause the dependent schedules first, then archive.

## Inline endpoints still work

You never have to use a profile. A schedule can carry its endpoint inline
(`endpoint` + `headers` + `retry_policy` + …) exactly as before — see
[One-shot deliveries](/docs/guides/one-shot/). Reach for a profile when the same
destination is used by more than one schedule, or when you want one place to rotate auth
and tune delivery policy.
