# LastPing Terraform provider — reference for agents

LastPing is a **dead-man's-switch monitor**: something checks in on a schedule, and if the check-in doesn't arrive, LastPing opens an incident and alerts a human. The Terraform provider declares those monitors — plus alert destinations, routing, message templates, status pages and API keys — as code, so the monitoring for a job lands in the same pull request as the job itself.

This file is the dense version, written to be read in full by an agent. The prose version is at https://lastping.dev/terraform, and the complete generated schema is at https://registry.terraform.io/providers/lastping-dev/lastping/latest/docs.

- Provider source address: `lastping-dev/lastping`
- Current version: `0.1.0` (pin `~> 0.1`)
- Terraform: **>= 1.5**, and **>= 1.10** for the ephemeral `lastping_api_key`
- Six managed resources, one ephemeral resource, six data sources
- Licence: MPL-2.0 — https://github.com/lastping-dev/terraform-provider-lastping

## Rules

- **Never reuse a slug.** Creating a monitor whose slug already exists fails with a 412 — the provider sends `If-None-Match: *` so an existing monitor is never silently taken over. Import it instead: `terraform import lastping_monitor.<label> <slug>`. The same applies to routes and alert templates: Terraform refuses to adopt ones it did not create, because one write replaces the whole set and would overwrite somebody else's alerting.
- **Never hardcode a destination secret.** Use `var.` with `sensitive = true`. Secrets are stored in Terraform state — `sensitive` only obscures CLI output, it does not affect storage — so use a remote backend with encryption at rest, or prefer `ephemeral.lastping_api_key` for run-scoped credentials.
- **`grace_s` is required** for `heartbeat` and `ci` monitors and validated to `[60, 31536000]`. There is no default. Omitting it returns `400 grace 0s outside [60, 31536000]`.
- **Alert template placeholders are `{token}`, not Go templates.** `{{.CheckName}}` passes validation and renders *literally* into a real alert.
- **Status page slugs are globally unique across every LastPing account**, not scoped to your project. Set `lifecycle { create_before_destroy = true }` on any public page.

## Install

```hcl
terraform {
  required_providers {
    lastping = {
      source  = "lastping-dev/lastping"
      version = "~> 0.1"
    }
  }
}

provider "lastping" {
  # Reads LASTPING_API_KEY from the environment. Do not put the key here.
}
```

`terraform init` downloads and signature-verifies the provider from the public registry.

### Provider schema

| Attribute | Type | Notes |
| --- | --- | --- |
| `api_key` | String, Sensitive | Optional. Prefer the `LASTPING_API_KEY` env var. Create a key at app.lastping.dev → Settings → API keys. |
| `endpoint` | String | Optional. Base URL, or `LASTPING_ENDPOINT`. Defaults to `https://app.lastping.dev`. |

One key belongs to one project, and the provider is scoped to that project.

## Resources

### `lastping_monitor`

A heartbeat, CI or HTTP probe monitor.

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `name` | String | yes | Human-readable name. |
| `slug` | String | no | Stable, project-scoped identifier used for import. `^[a-z0-9][a-z0-9-]{1,48}[a-z0-9]$`, not UUID-shaped. Changing it replaces the resource. |
| `monitor_type` | String | no | `heartbeat` (default), `ci`, `http`. Create-only — changing it replaces the resource. |
| `schedule_kind` | String | no | `simple` (`period_s`) or `cron` (`cron_expr` + `tz`). Computed for `http`. |
| `period_s` | Number | no | Interval in seconds for `schedule_kind = "simple"`. |
| `cron_expr` | String | no | 5-field cron expression for `schedule_kind = "cron"`. |
| `tz` | String | no | IANA timezone for cron evaluation. Defaults to `UTC`. |
| `grace_s` | Number | no* | *Required by the API for `heartbeat` and `ci`. `[60, 31536000]`. For `http` the server floors the effective grace at `2 × probe_interval_s`; omit it to take the floor, and set it only to ask for more. |
| `tags` | Set of String | no | Labels. Removing them from config clears them. |
| `runaway_ceiling` | Number | no | Cap on pings per rolling 1-hour window; exceeding it opens a `runaway` incident. |
| `paused` | Boolean | no | Suspends alerting. Maps onto the pause/resume endpoints. |
| `monitor_from` | String | no | RFC 3339 timestamp from which deadlines are computed. |
| `probe_url` | String | no | Absolute http(s) URL. Required for `monitor_type = "http"`. |
| `probe_method` | String | no | `GET`, `HEAD`, `POST`. |
| `probe_interval_s` | Number | no | Seconds between probes, `[30, 86400]`. |
| `probe_timeout_s` | Number | no | `[1, 30]`, defaults to 10 server-side. |
| `probe_expected_status` | Number | no | Status the probe must return. Omit to accept any 2xx. |
| `probe_expected_body` | String | no | Substring the response body must contain. |
| `probe_follow_redirects` | Boolean | no | Whether the probe follows redirects. |

Read-only: `id` (UUID), `ping_url`, `status` (`new`/`up`/`late`/`down`), `due_at`, `alert_after`, `last_ping_at`, `maintenance_until`, `created_at`.

CI binding (`ci_provider`, `ci_workflow`, `ci_branch`) is **not** manageable through Terraform: binding mints a write-once secret the API returns only at creation and can never read back. Configure it in the dashboard.

### `lastping_destination`

Where alerts are delivered. Credentials are write-only: stored, never returned by the API.

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `kind` | String | yes | `webhook`, `telegram`, `discord`, `slack`, `msteams`, `googlechat`, `ntfy`, `pushover`, `email`. Changing it replaces the resource. |
| `name` | String | yes | Rename is an in-place update. |
| `webhook_url` | String, Sensitive | per-kind | Required for `slack`, `discord`, `msteams`, `googlechat`. |
| `url` | String | per-kind | Endpoint LastPing POSTs to. Required for `webhook`. |
| `secret` | String, Sensitive | per-kind | Signs webhook deliveries. Required for `webhook`. |
| `bot_token` | String, Sensitive | per-kind | Required for `telegram`. |
| `chat_id` | String | per-kind | Required for `telegram`. |
| `topic_url` | String | per-kind | Required for `ntfy`, e.g. `https://ntfy.sh/my-alerts`. |
| `token` | String, Sensitive | per-kind | Required for `pushover` (app token); optional for `ntfy` (bearer). |
| `user_key` | String, Sensitive | per-kind | Required for `pushover`. |
| `address` | String | per-kind | Required for `email`. Changing it clears `verified` and re-sends the confirmation. |

Read-only: `id`, `target` (non-secret delivery hint), `verified`, `disabled`, `disable_reason`, `created_at`.

An `email` destination stays unverified — and delivers nothing — until the recipient clicks the confirmation link. Routing to an unverified or disabled destination is rejected with `channel not verified or is disabled`.

### `lastping_route`

One monitor's alerts for one event type. **One resource per `(monitor_id, event_type)` pair** — the API replaces the whole destination list on every write.

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `monitor_id` | String | yes | Monitor UUID. Changing it replaces the resource. |
| `event_type` | String | yes | `down`, `recovery`, `fail`, `every-run`, `success`, `started`, `blocked`, `note`. Changing it replaces the resource. |
| `destination_ids` | List of String | yes | Ordered list; the order is real state. Duplicates are rejected at plan time. An empty list means "deliver nowhere for this event" — not the same as removing the resource. |

`down`, `recovery` and `fail` fire on a state change, so their volume is bounded by how often the monitor changes state. `every-run` is different: it fires once per completed run, success or failure, so its volume is bounded only by how often the monitor runs. It is not flap-damped — a `fail` is held for the flap window so a blip can be cancelled before it pages, whereas an `every-run` is released immediately. The notifier's rate cap (60 notifications/hour by default) is **per destination and per event class**: `down`, `recovery` and `fail` share an "alert" budget, while `every-run` draws on its own separate "informational" budget. A busy `every-run` route therefore cannot use up the budget a later `down` or `fail` needs — but it will still drop its own excess notifications once it exceeds its own cap. Point `every-run` at a low-stakes destination, not at the one that pages someone.

### `lastping_alert_template`

Custom alert message bodies for one monitor. One resource owns the monitor's **whole** template map.

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `monitor_id` | String | yes | Monitor UUID. Changing it replaces the resource. |
| `templates` | Map of String | yes | Keyed by `down` / `recovery` / `fail` / `every-run`, or `event/cause` for a narrower override (`down/silence`, `fail/runaway`, `fail/ci`). The more specific key wins. An empty map means "use the defaults". |

Tokens: `{check_name}` `{status}` `{event}` `{cause}` `{last_ping}` `{schedule}` `{incident_url}` `{run_url}` `{branch}` `{commit}` `{actor}` `{failing_stage}` `{duration}` `{latency}` `{status_code}` `{url}`. Not every token is populated for every event. `{failing_stage}` is CI-only and provider-dependent: always set on GitLab, set on GitHub only when the repository webhook also subscribes to the `workflow_job` event, and never set on Jenkins (the Notification plugin payload carries no step detail). An unrecognised token in `{token}` shape fails the apply and names the offending key; `{{.CheckName}}` does **not** match the token pattern, so it is accepted and renders literally.

### `lastping_status_page`

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `title` | String | yes | Rename is an in-place update. |
| `slug` | String | no | `/status/<slug>`. **Globally unique across every LastPing account.** Omit for a server-generated random slug. Changing it replaces the resource. |
| `visibility` | String | no | `private` (default) or `public`. |
| `check_ids` | List of String | no | Monitors shown, in display order. Duplicates rejected at plan time. |

Read-only: `id`, `public_url` (empty while private), `created_at`.

Free for individuals allows one public status page per project; private pages are unlimited.

### `lastping_api_key`

A long-lived key for automation that outlives the run. **The plaintext is stored in Terraform state.** No import — an imported key could never populate `key`. Every attribute forces replacement, and replacement revokes the old key.

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `name` | String | yes | Label. No rename path, so changing it replaces the key. |
| `expires_at` | String | no | RFC 3339, must be in the future, and **may not be later than the expiry of the key the provider is configured with**. Omit for a key that never expires. |

Read-only: `id` (not a credential), `key` (Sensitive, plaintext, **in state**), `prefix` (first 10 chars, deliberately non-secret), `created_at`.

**A key may not be given a later expiry than the key that created it.** Terraform creates this resource with the credential in `LASTPING_API_KEY`, so if that key has an expiry of its own, an `expires_at` beyond it is rejected at apply time. The refusal names the ceiling, so the fix is always to move `expires_at` to or before the provider key's own expiry.

With the current provider an **omitted** `expires_at` still means "never", whatever the provider key's expiry is. A future provider release will let an omitted value inherit the provider key's expiry instead; until then, nothing changes for configurations that leave it out.

**Set `expires_at` explicitly on any key you intend to keep.** It is the only value that does not change meaning when the credential running Terraform changes, and it is the value the cap is checked against.

### `ephemeral.lastping_api_key`

A key that exists only for the duration of one run and is revoked when it ends. Nothing about it is written to plan or state. **Requires Terraform >= 1.10.**

| Attribute | Type | Req | Notes |
| --- | --- | --- | --- |
| `name` | String | yes | Label shown in the dashboard while the key lives. |
| `ttl` | String | no | Go duration (`"45m"`, `"2h"`), default `"1h"`. Becomes the server-side `expires_at`. There is no endpoint to extend a key, so set it to exceed your longest apply. |

Read-only, all ephemeral: `id`, `key`, `prefix`.

```hcl
ephemeral "lastping_api_key" "run" {
  name = "terraform-run"
  ttl  = "30m"
}

provider "lastping" {
  alias   = "run"
  api_key = ephemeral.lastping_api_key.run.key
}
```

Minting the ephemeral key itself needs a credential, so the default provider still reads `LASTPING_API_KEY`; what changes is that resource operations run under a key that expires.

That is exactly the case to watch with `lastping_api_key`. Under this aliased provider the creating key lives for `ttl` — an hour by default — so any `lastping_api_key` resource created through it may not ask for an expiry more than `ttl` away, and the apply fails if it does. A key meant to outlive the run must be created by a provider whose own key lives at least that long, not by the ephemeral one.

## Data sources

| Data source | Inputs | Returns |
| --- | --- | --- |
| `lastping_project` | — | `project_id`. Doubles as a credential smoke-test at plan time. |
| `lastping_monitors` | `tag` (optional, exact match) | `monitors` — list of the full monitor attribute set. |
| `lastping_monitor` | `id` or `slug` (one, not both) | The full monitor attribute set. |
| `lastping_destination` | `id` or `name` (one, not both) | `kind`, `target`, `verified`, `disabled`, `disable_reason`, `created_at`. An ambiguous name is an error. |
| `lastping_incidents` | `monitor_id`, `limit` (default 50, capped 200) | `incidents` — `opened_at`, `closed_at`, `cause`, `detail`. Newest first. |
| `lastping_metrics` | — | `text` — Prometheus text exposition 0.0.4. |

## Worked example

Provider, monitor, destination, routing and alert wording — the whole chain. Applies as-is.

```hcl
terraform {
  required_providers {
    lastping = {
      source  = "lastping-dev/lastping"
      version = "~> 0.1"
    }
  }
}

provider "lastping" {
  # Reads LASTPING_API_KEY from the environment.
}

# Never hardcode a destination secret. Pass it in with TF_VAR_slack_webhook_url
# or a secrets manager.
variable "slack_webhook_url" {
  type      = string
  sensitive = true
}

resource "lastping_monitor" "nightly_backup" {
  name          = "Nightly backup"
  slug          = "nightly-backup"
  schedule_kind = "cron"
  cron_expr     = "0 3 * * *"
  tz            = "UTC"
  grace_s       = 1800

  tags = ["env:prod", "team:platform"]
}

resource "lastping_destination" "oncall_slack" {
  kind        = "slack"
  name        = "#oncall"
  webhook_url = var.slack_webhook_url
}

# One resource per (monitor, event type).
resource "lastping_route" "backup_down" {
  monitor_id      = lastping_monitor.nightly_backup.id
  event_type      = "down"
  destination_ids = [lastping_destination.oncall_slack.id]
}

resource "lastping_route" "backup_recovery" {
  monitor_id      = lastping_monitor.nightly_backup.id
  event_type      = "recovery"
  destination_ids = [lastping_destination.oncall_slack.id]
}

# Placeholders are {token}, not Go templates.
resource "lastping_alert_template" "nightly_backup" {
  monitor_id = lastping_monitor.nightly_backup.id

  templates = {
    "down"     = "{check_name} has not checked in. Last ping {last_ping}, expected {schedule}. {incident_url}"
    "recovery" = "{check_name} recovered."
  }
}

# Feed this to whatever runs the job.
output "backup_ping_url" {
  value = lastping_monitor.nightly_backup.ping_url
}
```

`terraform apply` creates five resources. The job then pings `backup_ping_url` on success; see https://lastping.dev/agents.md for the ping conventions (`/start`, `/fail`, `/<exit-code>`, `?rid=`).

## Import

Creating a resource that already exists fails rather than taking it over. Import instead.

| Resource | Import ID |
| --- | --- |
| `lastping_monitor` | Slug (preferred) or UUID — `terraform import lastping_monitor.nightly_backup nightly-backup` |
| `lastping_destination` | UUID, or the name when exactly one destination has it |
| `lastping_route` | `<monitor-uuid>:<event-type>` — `550e8400-…:down` |
| `lastping_alert_template` | The monitor's UUID |
| `lastping_status_page` | UUID, or the slug within your own project |
| `lastping_api_key` | Not importable |

Importing cannot recover credentials — the API never returns them. A destination comes back with its secret attribute empty, and the first apply after the import writes the configured value back as an in-place update, not a replacement.

## `export_terraform` — adopt what already exists

Instead of writing the HCL by hand, have LastPing generate it. The export renders existing monitors, destinations, routes, alert templates and status pages as HCL and pairs **every** resource with an `import` block, so adopting Terraform adopts what you already have rather than proposing to recreate it.

Three equivalent entry points:

- **MCP tool** `export_terraform` — arguments: `tag`, `monitor_slug`, `include` (comma-separated subset of `monitors,destinations,routes,templates,status_pages`). This is how an agent hands work it created over MCP back to a Terraform-managed repo.
- **REST API** `GET /api/v1/export/terraform` with the same query parameters.
- **UI** — *Export as Terraform* in the app, which downloads the same HCL.

```bash
curl -fsS -H "Authorization: Bearer $LASTPING_API_KEY" \
  https://app.lastping.dev/api/v1/export/terraform > lastping.tf
```

Output shape:

```hcl
# Generated by LastPing.
# Secrets are not exported — the API never returns them. Every `var.`
# reference below has a matching variable block you must fill in.
# Import blocks adopt your existing resources instead of recreating them.

import {
  to = lastping_monitor.nightly_backup
  id = "nightly-backup"
}

resource "lastping_monitor" "nightly_backup" {
  name          = "Nightly backup"
  slug          = "nightly-backup"
  schedule_kind = "cron"
  cron_expr     = "0 3 * * *"
  tz            = "UTC"
  grace_s       = 1800
  tags          = ["env:prod", "team:platform"]
}

import {
  to = lastping_destination.oncall
  id = "29145b3c-64c7-4b7f-91b7-a09c0eecaee1"
}

resource "lastping_destination" "oncall" {
  kind        = "slack"
  name        = "#oncall"
  webhook_url = var.slack_webhook_url_oncall
}

variable "slack_webhook_url_oncall" {
  # TODO: set this — not exported, the API never returns secrets.
  type        = string
  sensitive   = true
  description = "webhook_url for destination \"#oncall\""
}
```

Fill in the variables, then `terraform plan`. `Plan: 0 to add, 0 to change, 0 to destroy.` is the goal: the monitoring is in version control and nothing about it changed.

## Terraform vs MCP

Both reach the same API and the same monitors.

- **Terraform** when the monitoring belongs in a reviewed, versioned repo — an agent adding a scheduled job can add the `lastping_monitor` that watches it in the same commit.
- **MCP** (https://lastping.dev/mcp/) when an agent needs a monitor for itself right now, mid-conversation, with no pull request. `export_terraform` is how a monitor created over MCP later becomes code.

## Pricing

LastPing is free for individuals — no monitor cap, no feature gate. The provider is open source and adds no cost of its own. The one free-tier limit visible in Terraform is one public status page per project. A paid team tier may be introduced later.

## More

- Provider docs: https://registry.terraform.io/providers/lastping-dev/lastping/latest/docs
- Provider source: https://github.com/lastping-dev/terraform-provider-lastping
- Prose guide: https://lastping.dev/terraform
- Why monitoring as code: https://lastping.dev/monitoring-as-code
- Agent integration guide: https://lastping.dev/agents.md
- Docs hub: https://app.lastping.dev/docs/ · API reference: https://app.lastping.dev/docs/api/
- Contact: hello@lastping.dev
