Skip to content

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 ​

MethodEndpointNotes
POST/api/v1/messages/sendSchedule a single message
POST/api/v1/messages/bulkSchedule an entire batch for the same time
GET/api/v1/messages?status=scheduledList messages still awaiting their send time
POST/api/v1/messages/:id/cancelCancel a scheduled message and refund its credits

The scheduled_at field ​

ParameterTypeRequiredDescription
scheduled_atstringNoISO 8601 timestamp for when the message should be sent. Omit (or send null) to send immediately.

Rules ​

RuleDetail
FormatISO 8601 string, e.g. 2026-09-01T09:00:00Z
Must be in the futureA 30-second allowance absorbs minor client clock drift
Maximum horizon30 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 UTC
  • 2026-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 ​

bash
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"
    }
  }'
javascript
// 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);
python
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
<?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) ​

json
{
  "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"
  }
}
FieldDescription
statusscheduled while the message waits. An immediate send returns queued instead.
scheduled_atThe confirmed send time, echoed back in UTC. null for immediate sends.
credits_usedCredits 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.

bash
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:

json
{
  "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.

EventEffect on balance
Message scheduledCredits deducted (reserved)
Message sends at its scheduled timeNo further charge
Message cancelled before sendingCredits 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:

bash
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:

json
{
  "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/cancel

Cancels a message that has not sent yet and refunds its reserved credits.

bash
curl -X POST https://api.yebolink.com/api/v1/messages/550e8400-e29b-41d4-a716-446655440000/cancel \
  -H "X-API-Key: ybk_your_api_key"
javascript
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');
python
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) ​

json
{
  "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.

CodeWhenBody
404No message with that ID in your workspace{ "success": false, "error": "Message not found" }
409The 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:

MessageCause
scheduled_at must be an ISO 8601 stringThe value was not a string (e.g. a number or an object)
scheduled_at must be a valid ISO 8601 timestampThe string could not be parsed as a date
scheduled_at must be in the futureThe time has already passed (beyond the 30-second skew allowance)
scheduled_at cannot be more than 30 days in the futureBeyond 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:

javascript
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 ​

Built with VitePress