GET /v1/messages/{id}
Docs / Sending
GET

/v1/messages/{id}

One message from the archive: summary fields plus recipients, delivery events, and transport id. id is the UUID from POST /v1/messages — the same id the panel's Message Log shows.

Authentication

Requires messages:read. A key without it gets 403, a send-only key included.

Keys rule — prefix, shown once, abilities never widen, and no key carries both messages:send and messages:read-content.

Fields

evidenceOne of none, smtp_accepted, provider_delivered, provider_bounced, provider_complained, provider_deferred, suppressed. While status is sent, a delivered event on a transport that reports delivery is provider_delivered, including when an earlier deferral or another recipient's bounce or complaint is also recorded. Otherwise a complaint event is chosen first, then a bounce, then a defer while the message is still queued, sending, or deferred, then a delivered event on a transport that reports delivery. Otherwise sent is smtp_accepted, suppressed is suppressed, and failed or anything else is none. The word Delivered is the status label only when status is sent and evidence is provider_delivered. Sent with any other evidence, including smtp_accepted and provider_deferred, reads Handed to your server.
status_labelQueued, Sending, Handed to your server, Delivered, Failed, Bounced, Complained, or Suppressed. Bounced is a failed message whose evidence is provider_bounced. Complained is a failed message whose evidence is provider_complained. Any other failure reads Failed.
events[].typeWritten by us: queued or suppressed when the message is accepted, then sent, failed, deferred while an attempt will be retried, and bounce_unattributed when a hard bounce on several recipients names none of them. A provider report is stored as delivered, bounced, complained, unsubscribed, or deferred. A generic payload keeps the type it arrived with, except bounce is stored as bounced and complaint as complained.
events[].sourceinternal when we record the event, webhook when a provider report does.
recipients[].kindto, cc, or bcc.
recipients[].statusqueued or suppressed when the message is accepted. Later sent, failed, or suppressed.
recipients[].suppressed_reasonNull unless that recipient is suppressed. Then the suppression's reason: manual, bounce, complaint, or unsubscribe. Delivery writes erasure when it finds an address an erasure has already hashed.
template_version_idPresent on this response only. The template version that rendered the message, or null when the send had no template. It matches versions[].id on GET /v1/templates/{id}. The list response does not include this field.
subject_redactedTrue when the template is marked sensitive. The message subject is then null. The real subject is stored with the body and returned by the content endpoint only for a read-content key minted by an owner. A read-content key minted by anyone else is 403, type sealed_body_denied.
content_expires_atNull until the message reaches a final state. Then the completion time when the retention in force is 0 days, otherwise that time plus the retention days. The retention is the template's own setting if it has one, otherwise the account's, capped by the plan. The stored body carries the same instant. Opening it after that time is 410, message Content expired.
attachments[]Always present on this response, an empty list when the message has none. Each object is id, filename, content_type, size_bytes, and has_content. has_content is true when the ciphertext or a storage path is present. The bytes are not in this response.

Worked example

curl
curl https://spoolway.com/api/v1/messages/2ee35675-6bae-42be-aa5e-6d4618d0c61d \
  -H "Authorization: Bearer spw_…"
PHP
$ch = curl_init("https://spoolway.com/api/v1/messages/" . $id);
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SPOOLWAY_KEY")],
]);
$detail = json_decode(curl_exec($ch), true);
Node
const res = await fetch(`https://spoolway.com/api/v1/messages/${id}`, {
  headers: { Authorization: `Bearer ${process.env.SPOOLWAY_KEY}` },
});
const detail = await res.json();
Python
import os, requests

detail = requests.get(
    f"https://spoolway.com/api/v1/messages/{id}",
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/messages/#{id}")
req = Net::HTTP::Get.new(uri,
  { "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
detail = JSON.parse(res.body)
200 OKone message
{
  "data": {
    "id": "2ee35675-6bae-42be-aa5e-6d4618d0c61d",
    "status": "queued",
    "status_label": "Queued",
    "evidence": "none",
    "supports_feedback": false,
    "application_id": 1,
    "template_id": 1,
    "subject": "Reset password",
    "subject_redacted": false,
    "from_address": "[email protected]",
    "queued_at": "2026-08-06T06:55:48.000000Z",
    "completed_at": null,
    "content_expires_at": null,
    "tags": { "env": "docs" },
    "marketing": false,
    "template_version_id": 1,
    "recipients": [
      {
        "kind": "to",
        "email": "[email protected]",
        "name": "Casey Reid",
        "status": "queued",
        "suppressed_reason": null
      }
    ],
    "events": [
      {
        "type": "queued",
        "occurred_at": "2026-08-06T06:55:48.000000Z",
        "source": "internal",
        "payload": null
      }
    ],
    "transport_id": 1,
    "attachments": []
  }
}

Responses & failures

Unknown id → 404. Wrong ability → 403. No body fields here by design.