Scheduling Messages
Queue a message now and have YeboLink deliver it at a future time, instead of sending immediately.
Add a scheduled_at timestamp to any send request. The message is created straight away with status scheduled, its credits are reserved, and it is handed to the delivery pipeline only when the scheduled time arrives.
The field is scheduled_at
Not send_at. Unknown fields are ignored rather than rejected, so a misspelled field name means your message sends immediately and is charged — with no error to tell you why.
Supported endpoints
| Method | Endpoint | Notes |
|---|---|---|
| POST | /api/v1/messages/send | Schedule a single message |
| POST | /api/v1/messages/bulk | Schedule an entire batch for the same time |
| GET | /api/v1/messages?status=scheduled | List messages still awaiting their send time |
| POST | /api/v1/messages/:id/cancel | Cancel a scheduled message and refund its credits |
The scheduled_at field
| Parameter | Type | Required | Description |
|---|---|---|---|
scheduled_at | string | No | ISO 8601 timestamp for when the message should be sent. Omit (or send null) to send immediately. |
Rules
| Rule | Detail |
|---|---|
| Format | ISO 8601 string, e.g. 2026-09-01T09:00:00Z |
| Must be in the future | A 30-second allowance absorbs minor client clock drift |
| Maximum horizon | 30 days ahead |
Omitted / null / "" | Treated as "send now" — the normal immediate path |
Timezones
Always send an explicit UTC offset
scheduled_at is parsed as an ISO 8601 instant. Include a Z (UTC) or an explicit offset so the send time is unambiguous:
2026-09-01T09:00:00Z— 09:00 UTC2026-09-01T09:00:00+02:00— 09:00 SAST (07:00 UTC)
A bare timestamp with no offset — 2026-09-01T09:00:00 — is interpreted relative to the server's clock, not yours, so the message can fire hours away from when you intended. Build the value from a real instant (new Date(...).toISOString(), datetime.now(timezone.utc).isoformat()) rather than string-formatting a local wall-clock time.
Schedule a single message
curl -X POST https://api.yebolink.com/api/v1/messages/send \
-H "Content-Type: application/json" \
-H "X-API-Key: ybk_your_api_key" \
-d '{
"to": "+26878422613",
"channel": "sms",
"content": {
"text": "Reminder: your appointment is tomorrow at 10:00."
},
"scheduled_at": "2026-09-01T09:00:00Z",
"metadata": {
"purpose": "appointment_reminder"
}
}'// Schedule a message for 24 hours from now.
const sendAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
const response = await fetch('https://api.yebolink.com/api/v1/messages/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.YEBOLINK_API_KEY
},
body: JSON.stringify({
to: '+26878422613',
channel: 'sms',
content: {
text: 'Reminder: your appointment is tomorrow at 10:00.'
},
// toISOString() always emits UTC with a trailing Z — never ambiguous.
scheduled_at: sendAt.toISOString(),
metadata: { purpose: 'appointment_reminder' }
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || error.message);
}
const { data } = await response.json();
console.log('Scheduled:', data.message_id, 'for', data.scheduled_at);import os
from datetime import datetime, timedelta, timezone
import requests
# Build the instant in UTC so the offset is explicit.
send_at = datetime.now(timezone.utc) + timedelta(days=1)
response = requests.post(
'https://api.yebolink.com/api/v1/messages/send',
headers={'X-API-Key': os.environ['YEBOLINK_API_KEY']},
json={
'to': '+26878422613',
'channel': 'sms',
'content': {
'text': 'Reminder: your appointment is tomorrow at 10:00.'
},
'scheduled_at': send_at.isoformat(),
'metadata': {'purpose': 'appointment_reminder'},
},
)
response.raise_for_status()
data = response.json()['data']
print(f"Scheduled: {data['message_id']} for {data['scheduled_at']}")<?php
// One day from now, in UTC.
$sendAt = (new DateTime('now', new DateTimeZone('UTC')))
->add(new DateInterval('P1D'))
->format('Y-m-d\TH:i:s\Z');
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => 'https://api.yebolink.com/api/v1/messages/send',
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . getenv('YEBOLINK_API_KEY'),
],
CURLOPT_POSTFIELDS => json_encode([
'to' => '+26878422613',
'channel' => 'sms',
'content' => ['text' => 'Reminder: your appointment is tomorrow at 10:00.'],
'scheduled_at' => $sendAt,
'metadata' => ['purpose' => 'appointment_reminder'],
]),
]);
$response = curl_exec($curl);
$httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
if ($httpCode !== 201) {
throw new Exception('Failed to schedule: ' . $response);
}
$data = json_decode($response, true)['data'];
echo "Scheduled: {$data['message_id']} for {$data['scheduled_at']}\n";
?>Response (201 Created)
{
"success": true,
"data": {
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "scheduled",
"credits_used": "1.00",
"scheduled_at": "2026-09-01T09:00:00.000Z",
"created_at": "2026-08-28T12:00:00.000Z"
}
}| Field | Description |
|---|---|
status | scheduled while the message waits. An immediate send returns queued instead. |
scheduled_at | The confirmed send time, echoed back in UTC. null for immediate sends. |
credits_used | Credits reserved for this message — already deducted from your balance. |
Schedule a bulk send
POST /api/v1/messages/bulk takes the same scheduled_at field. It applies to every recipient in the batch — they are all scheduled for the same instant.
curl -X POST https://api.yebolink.com/api/v1/messages/bulk \
-H "Content-Type: application/json" \
-H "X-API-Key: ybk_your_api_key" \
-d '{
"recipients": [
{ "to": "+26878422613", "name": "John" },
{ "to": "+26878422614", "name": "Jane" }
],
"channel": "sms",
"content": {
"text": "Hi {{name}}, our sale starts tomorrow morning!"
},
"scheduled_at": "2026-09-01T06:00:00Z"
}'The bulk response reports the batch, not per-message schedule details. Use the returned message_ids (or GET /api/v1/messages?status=scheduled) to inspect or cancel individual messages:
{
"success": true,
"data": {
"queued": 2,
"failed": 0,
"message_ids": [
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8"
]
}
}Credits are reserved up front
Scheduling deducts credits immediately, at the moment you schedule — not at send time.
This is deliberate: it guarantees a scheduled message can still be paid for when its send time arrives, so a batch scheduled today cannot silently fail next week because the balance was spent elsewhere in the meantime.
| Event | Effect on balance |
|---|---|
| Message scheduled | Credits deducted (reserved) |
| Message sends at its scheduled time | No further charge |
| Message cancelled before sending | Credits refunded in full |
Reserved credits appear in your transaction history as Reserved for scheduled message to …, and refunds as Refund: cancelled scheduled message to ….
List pending scheduled messages
There is no separate "scheduled messages" endpoint — filter the standard message list by status:
curl "https://api.yebolink.com/api/v1/messages?status=scheduled&limit=50" \
-H "X-API-Key: ybk_your_api_key"Each returned message carries its scheduled_at, so you can sort or display upcoming sends:
{
"success": true,
"data": {
"messages": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"channel": "sms",
"recipient": "+26878422613",
"status": "scheduled",
"scheduled_at": "2026-09-01T09:00:00.000Z",
"credits_used": "1.00",
"created_at": "2026-08-28T12:00:00.000Z"
}
],
"pagination": { "total": 1, "page": 1, "pages": 1, "limit": 50 }
}
}The status filter accepts scheduled, queued, sent, delivered, failed, read and cancelled. See Retrieve Messages for the full set of filters and pagination.
Cancel a scheduled message
POST /api/v1/messages/:id/cancelCancels a message that has not sent yet and refunds its reserved credits.
curl -X POST https://api.yebolink.com/api/v1/messages/550e8400-e29b-41d4-a716-446655440000/cancel \
-H "X-API-Key: ybk_your_api_key"const cancelScheduled = async (messageId) => {
const response = await fetch(
`https://api.yebolink.com/api/v1/messages/${messageId}/cancel`,
{
method: 'POST',
headers: { 'X-API-Key': process.env.YEBOLINK_API_KEY }
}
);
const body = await response.json();
if (!response.ok) {
// 409 means it already sent — there is nothing left to cancel.
throw new Error(body.error || body.message);
}
return body.data;
};
const result = await cancelScheduled('550e8400-e29b-41d4-a716-446655440000');
console.log('Refunded:', result.credits_refunded, 'credits');import os
import requests
message_id = '550e8400-e29b-41d4-a716-446655440000'
response = requests.post(
f'https://api.yebolink.com/api/v1/messages/{message_id}/cancel',
headers={'X-API-Key': os.environ['YEBOLINK_API_KEY']},
)
response.raise_for_status()
data = response.json()['data']
print(f"Refunded: {data['credits_refunded']} credits")Response (200 OK)
{
"success": true,
"data": {
"message_id": "550e8400-e29b-41d4-a716-446655440000",
"status": "cancelled",
"credits_refunded": "1.00"
}
}Cancellation is only possible while scheduled
Once a message leaves the scheduled state it is on its way to the carrier and can no longer be recalled.
| Code | When | Body |
|---|---|---|
| 404 | No message with that ID in your workspace | { "success": false, "error": "Message not found" } |
| 409 | The message is no longer scheduled | { "success": false, "error": "Only scheduled messages can be cancelled (this one is 'sent')." } |
Cancel races the send
If you cancel at almost exactly the scheduled time, the send may win the race and you will get a 409 (Message can no longer be cancelled; it may have already been sent.). Treat a 409 as "already delivered to the carrier", not as a retryable error.
Cancelling requires the send_messages scope — the same scope as sending. Listing scheduled messages requires read_messages.
Errors
All scheduling validation failures return 400 Bad Request before any credits are touched:
| Message | Cause |
|---|---|
scheduled_at must be an ISO 8601 string | The value was not a string (e.g. a number or an object) |
scheduled_at must be a valid ISO 8601 timestamp | The string could not be parsed as a date |
scheduled_at must be in the future | The time has already passed (beyond the 30-second skew allowance) |
scheduled_at cannot be more than 30 days in the future | Beyond the maximum scheduling horizon |
A 402 is returned if your balance cannot cover the reservation, and a 503 (Scheduling is unavailable…) if the scheduling backend is temporarily unable to accept the deferred send. In both cases you are not charged — any credits already reserved are refunded automatically, and the message is recorded as failed rather than being sent at some later point.
Best practices
Pair scheduling with an idempotency key
A retried schedule request without an Idempotency-Key creates a second scheduled message and reserves credits twice. Send the header on every scheduled send:
await fetch('https://api.yebolink.com/api/v1/messages/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.YEBOLINK_API_KEY,
'Idempotency-Key': `reminder-${appointmentId}`
},
body: JSON.stringify({
to: phone,
channel: 'sms',
content: { text: 'Reminder: your appointment is tomorrow at 10:00.' },
scheduled_at: sendAt.toISOString()
})
});Store the message_id
Keep the returned message_id against your own record. It is the only handle for cancelling the send later — if the appointment moves or the customer opts out, you need it to call the cancel endpoint.
Reschedule = cancel, then re-send
There is no "update the schedule" endpoint. To move a send, cancel the existing message (which refunds the credits) and create a new one at the new time.
Watch the 30-day horizon
For anything further out than 30 days, keep the schedule in your own system and call YeboLink once the send falls inside the window.
Next Steps
- Idempotency — retry sends safely without double-charging
- Retrieve Messages — filter, paginate, and inspect message history
- Send Bulk Messages — personalise a batch with template variables
- Setup Webhooks — get notified when a scheduled message finally delivers