Cancelling Scheduled Messages
POST /v3/messages/{id}/cancel calls off a message you scheduled with scheduled_at, as long as it is still being held. The message moves to CANCELLED, which is terminal, and it is never sent.
Cancelling is the counterpart to scheduling. It is free, nothing was charged for the hold in the first place, and a cancelled message cannot be resent. For the generated schema and the interactive request builder, see Cancel a scheduled message in the API reference.
What Can Be Cancelled
Only a message whose current status is SCHEDULED. That covers both ways a message is held: one you scheduled with scheduled_at, and one the recipient's quiet hours deferred.
| Message status | Cancellable? | Notes |
|---|---|---|
SCHEDULED | Yes | Held for a scheduled_at instant or a quiet-hours window |
CANCELLED | Yes, as a repeat | Returns 200 with the same body and fires no second webhook |
QUEUED, PROCESSED, ROUTED, SENT | No | Already released and in flight |
DELIVERED, READ, FAILED, FILTERED, BLOCKED | No | Finished |
Everything in the third and fourth rows returns 409 with error code BUSINESS_006.
The release sweeper checks for due messages every 30 seconds, so a cancellation sent within about a minute of the scheduled instant can lose that race and be refused with 409. Cancel with time to spare.
Lifecycle
Once the release sweeper has claimed the message, the send is in flight and the cancellation is refused. The two paths are mutually exclusive: a message is either cancelled or released, never both.
Making a Request
The request body is empty. The message to cancel is identified by the id path parameter.
curl -X POST "https://api.sent.dm/v3/messages/8ba7b830-9dad-11d1-80b4-00c04fd430c8/cancel" \
-H "x-api-key: $SENT_API_KEY" \
-H "Content-Type: application/json"import SentDm from '@sentdm/sentdm';
const client = new SentDm();
const response = await client.messages.cancel('8ba7b830-9dad-11d1-80b4-00c04fd430c8');
console.log(response.data.status); // "CANCELLED"
console.log(response.data.scheduled_at); // the send time that was called offimport os
from sentdm import SentDm
client = SentDm(api_key=os.environ.get("SENT_API_KEY"))
response = client.messages.cancel("8ba7b830-9dad-11d1-80b4-00c04fd430c8")
print(response.data.status) # "CANCELLED"
print(response.data.scheduled_at) # the send time that was called offpackage main
import (
"context"
"fmt"
"os"
sentdm "github.com/sentdm/sentdm-go"
)
func main() {
client := sentdm.NewClient(os.Getenv("SENT_API_KEY"))
response, err := client.Messages.Cancel(context.Background(), "8ba7b830-9dad-11d1-80b4-00c04fd430c8")
if err != nil {
panic(err)
}
fmt.Println(response.Data.Status) // "CANCELLED"
fmt.Println(response.Data.ScheduledAt) // the send time that was called off
}import com.sentdm.SentDm;
import com.sentdm.models.MessageResponse;
public class CancelExample {
public static void main(String[] args) {
SentDm client = new SentDm(System.getenv("SENT_API_KEY"));
MessageResponse response = client.messages().cancel("8ba7b830-9dad-11d1-80b4-00c04fd430c8");
System.out.println(response.getData().getStatus()); // "CANCELLED"
System.out.println(response.getData().getScheduledAt()); // the send time that was called off
}
}using SentDm;
var client = new SentDmClient(Environment.GetEnvironmentVariable("SENT_API_KEY"));
var response = await client.Messages.CancelAsync("8ba7b830-9dad-11d1-80b4-00c04fd430c8");
Console.WriteLine(response.Data.Status); // "CANCELLED"
Console.WriteLine(response.Data.ScheduledAt); // the send time that was called off<?php
use SentDm\SentDmClient;
$client = new SentDmClient(getenv('SENT_API_KEY'));
$response = $client->messages->cancel('8ba7b830-9dad-11d1-80b4-00c04fd430c8');
echo $response->data->status; // "CANCELLED"
echo $response->data->scheduled_at; // the send time that was called offrequire 'sentdm'
client = SentDm::Client.new(api_key: ENV['SENT_API_KEY'])
response = client.messages.cancel('8ba7b830-9dad-11d1-80b4-00c04fd430c8')
puts response.data.status # "CANCELLED"
puts response.data.scheduled_at # the send time that was called offResponse
A 200 OK response means the message is cancelled and will not be sent. The body is the message itself, in the same shape GET /v3/messages/{id} returns, plus scheduled_at:
{
"success": true,
"data": {
"id": "8ba7b830-9dad-11d1-80b4-00c04fd430c8",
"customer_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"contact_id": "5ba7b800-9dad-11d1-80b4-00c04fd430c8",
"phone": "+14155551234",
"phone_international": "+1 415-555-1234",
"region_code": "US",
"template_id": "7ba7b820-9dad-11d1-80b4-00c04fd430c8",
"template_name": "order_confirmation",
"channel": "sms",
"status": "CANCELLED",
"scheduled_at": "2026-10-01T07:00:00Z",
"direction": "OUTBOUND",
"created_at": "2026-09-30T11:59:00Z",
"events": [
{
"status": "QUEUED",
"timestamp": "2026-09-30T11:59:00Z"
},
{
"status": "SCHEDULED",
"description": "Send scheduled by the customer for a later time. Scheduled to send at 2026-10-01 07:00:00Z.",
"scheduled_at": "2026-10-01T07:00:00Z",
"timestamp": "2026-09-30T11:59:02Z"
}
]
},
"error": null,
"meta": {
"request_id": "req_9Y0aLq3kEx",
"timestamp": "2026-09-30T12:04:11Z",
"version": "v3"
}
}Two fields are worth reading carefully:
statusis read off the row the cancellation just wrote, so it is the outcome rather than a prediction. Branch on it.eventsis the message timeline as it stood immediately before the cancellation. TheCANCELLEDentry is written asynchronously and lands a moment later, so it is not in this body.GET /v3/messages/{id}is cached briefly, so an immediate re-fetch can still show the pre-cancellation timeline; re-fetch after a few seconds if you need the entry itself.
scheduled_at is the instant the message was being held for, in UTC: the time it would have gone out. It survives the cancellation, so GET /v3/messages/{id} and GET /v3/messages/{id}/activities keep reporting it afterwards.
One Message Per Call
A cancellation names one message. A send to several recipients, or on several channels, becomes one message per recipient per channel, each with its own ID, so cancel the ones you want stopped:
for id in "$MESSAGE_ID_1" "$MESSAGE_ID_2"; do
curl -X POST "https://api.sent.dm/v3/messages/$id/cancel" \
-H "x-api-key: $SENT_API_KEY"
doneThere is no bulk form and no batch identifier on the send response. Record the message_id of each recipient from the send response if you expect to cancel the send later.
Repeating a Cancellation
Cancelling a message that is already CANCELLED returns 200 with the same body. It is not a 409: you asked for the message not to be sent, and it will not be sent.
The repeat changes nothing. It writes no second status, fires no second message.cancelled webhook, and adds no second entry to the activity timeline. Only the first call that found the message SCHEDULED published anything.
The message.cancelled Webhook
A cancellation fires message.cancelled exactly once, on the first call that took effect.
| Payload field | Value on message.cancelled |
|---|---|
message_status | CANCELLED |
scheduled_at | The release instant that was called off, in UTC. The same value message.scheduled carried for this message |
body | null. A cancelled send never resolved a channel, so there is no rendered copy to publish |
schedule_reason | Omitted. It explains why the message was held, which is a property of the hold rather than of the cancellation |
reason_code, reason | Omitted. Sent has nothing to explain about a send you stopped yourself |
Everything else is the shape every message status event carries. See Outbound message event fields.
scheduled_at is the field to key on: a consumer that recorded a future send when message.scheduled arrived has what it needs to un-record it. Webhook delivery is neither ordered nor guaranteed, so a consumer that missed message.scheduled still learns from the cancellation alone that there is no future send to wait for.
message.cancelled is a new sub-type. If you subscribe to the message parent event, you receive it without any change. If you subscribe with event_filters, add cancelled to the message list to receive it.
The Activity Timeline
GET /v3/messages/{id}/activities gains a CANCELLED entry carrying the same scheduled_at:
{
"status": "CANCELLED",
"description": "Send cancelled by the customer before release. It was scheduled to send at 2026-10-01 07:00:00Z.",
"scheduled_at": "2026-10-01T07:00:00Z",
"timestamp": "2026-09-30T12:04:12Z"
}The entry is written asynchronously, so it appears within a second or two of the 200 rather than in the cancellation response itself.
Billing
Cancelling is free, and nothing had been charged for the message in the first place. A scheduled message is priced and billed at release, not when you schedule it, so a message cancelled while held costs nothing.
A cancelled message is also excluded from your deliverability rate in both the numerator and the denominator. It was never a send attempt.
A Cancelled Message Is Final
CANCELLED is terminal. The message is never released, never routed, and never dispatched, and POST /v3/messages/{id}/resend on it returns 409. To send after cancelling, create a new message with POST /v3/messages.
Idempotency
The Idempotency-Key header is honored. Retrying a cancellation with the same key replays the original 200 response rather than re-executing the call. A retry sent while the first request is still in flight is answered with 409 CONFLICT_001; retry it once the original completes. See Idempotency.
Idempotency is a convenience here rather than a safeguard. A repeated cancellation without a key is already harmless: it answers 200 and publishes nothing a second time.
Rate Limiting
This endpoint is not in the Sensitive tier that resend sits in. A resend bills on every accepted call, so it earns a tight ceiling; a cancellation bills nothing and reduces spend. The general v3 limits apply. See Rate limits.
Sandbox
This endpoint supports sandbox mode. Send {"sandbox": true} as the body and the API validates and authenticates the request, then returns a simulated 200 reporting CANCELLED without reading or writing anything. No message is cancelled, no webhook is delivered.
Error Reference
| HTTP status | Error code | Cause |
|---|---|---|
400 | VALIDATION_001 | The message ID is not a valid UUID. The error details are keyed on id |
401 | AUTH_001, AUTH_002 | The x-api-key header is missing, or its value is not a recognized key |
404 | RESOURCE_003 | No such message for this account |
409 | BUSINESS_006 | The message is not SCHEDULED, so there is nothing to cancel |
409 | CONFLICT_001 | A request with the same Idempotency-Key is still being processed. Retry with the same key once it completes to receive its cached response |
429 | BUSINESS_002 | Rate limit exceeded |
500 | INTERNAL_001 | Unexpected error |
The 409 body carries this message:
Only a scheduled message can be cancelled. This message has already been released for sending, is in flight, or has finished.
It is a state conflict, not a transient one: retrying returns the same 409, because a message that has left SCHEDULED never returns to it. Read the current status with GET /v3/messages/{id} to see where the message went.
A message ID that belongs to another account returns 404, not 403, so the response does not reveal whether the ID exists.
Next Steps
Scheduled Messages
Schedule a send with scheduled_at, and the rules that govern the release instant
Status Tracking
Track every status change with webhooks and polling
Event Types
Every webhook sub-type and the payload each one carries
API Reference
Generated schema, parameters, and response shapes for the cancel endpoint