# CallMeLater Documentation > CallMeLater is a developer-first API for scheduling durable HTTP calls and interactive human reminders. Authenticate with Bearer tokens (sk_live_...). Two core primitives: (1) Scheduled webhooks — fire HTTP requests at a future time with automatic retries, (2) Approval reminders — send yes/no/snooze prompts via email or SMS with escalation. Also supports multi-step chains (workflows) and reusable templates. SDKs available for Node.js, Laravel/PHP, and n8n. This file contains all documentation content in a single document following the llmstxt.org standard. ## CallMeLater Schedule durable HTTP calls and interactive human approvals. One API, automatic retries, full audit trail. ## What it does - **Scheduled webhooks** — Fire any HTTP request minutes, days, or months from now. Retries automatically on failure. - **Human approvals** — Send Yes/No/Snooze approval requests via email, SMS, Teams, or Slack. Get notified when someone responds. - **Multi-step workflows** — Chain webhooks, approvals, and wait steps into sequential workflows with data passing. :::info What CallMeLater is NOT It's not a cron scheduler, a message queue, or a workflow engine. It schedules discrete future actions and delivers them reliably. ::: ## Choose your path Quick Start Schedule your first action in 60 seconds with curl. Get started → Node.js SDK TypeScript, zero dependencies, fluent API. Install SDK → Laravel SDK Facades, fluent builders, webhook events. Install SDK → n8n Integration Visual workflows with trigger and action nodes. Set up n8n → API Reference Full endpoint documentation for direct HTTP usage. View API docs → --- ## Quick Start Schedule your first action in 60 seconds. ## 1. Get your API key Sign up at [callmelater.io](https://callmelater.io) and create an API token from **Settings → API Tokens**. ## 2. Schedule an HTTP call ```bash curl https://api.callmelater.io/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "schedule": { "wait": "5m" }, "request": { "url": "https://your-app.com/webhook", "method": "POST", "body": { "event": "test", "message": "Hello from CallMeLater" } } }' ``` This schedules a POST request to your URL in 5 minutes. The response includes the action ID: ```json { "data": { "id": "act_abc123...", "status": "scheduled", "scheduled_for": "2025-06-15T14:35:00Z" } } ``` ## 3. Check the status ```bash curl https://api.callmelater.io/v1/actions/act_abc123 \ -H "Authorization: Bearer sk_live_..." ``` ## 4. Cancel it ```bash curl -X DELETE https://api.callmelater.io/v1/actions/act_abc123 \ -H "Authorization: Bearer sk_live_..." ``` ## 5. Send an approval reminder ```bash curl https://api.callmelater.io/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "approval", "name": "Approve deployment", "schedule": { "wait": "1s" }, "gate": { "message": "Ready to deploy v2.1 to production?", "recipients": ["ops@example.com"], "buttons": ["Approve", "Reject"] }, "callback_url": "https://your-app.com/webhook" }' ``` When someone responds, CallMeLater sends the response to your `callback_url`. ## Next steps - **[Node.js SDK](/sdks/nodejs)** — Fluent TypeScript API, install with `npm install callmelater` - **[Laravel SDK](/sdks/laravel)** — Facades and fluent builders, install with `composer require callmelater/laravel` - **[Core Concepts](/concepts/actions)** — Scheduling, lifecycle states, idempotency - **[API Reference](/api/authentication)** — Full endpoint documentation --- ## Laravel SDK Fluent Laravel SDK for CallMeLater with Facades, webhook events, and Artisan commands. ## Installation ```bash composer require callmelater/laravel ``` Add to your `.env`: ```env CALLMELATER_API_TOKEN=sk_live_your_token_here CALLMELATER_WEBHOOK_SECRET=your_webhook_secret ``` Optionally publish the config: ```bash php artisan vendor:publish --tag=callmelater-config ``` ## HTTP Actions ```php use CallMeLater\Laravel\Facades\CallMeLater; // Simple CallMeLater::http('https://api.example.com/process') ->post() ->payload(['user_id' => 123]) ->inHours(2) ->send(); // With all options CallMeLater::http('https://api.example.com/webhook') ->name('Process order #456') ->post() ->headers(['X-Custom' => 'value']) ->payload(['order_id' => 456]) ->at(now()->addDays(3)) ->timezone('America/New_York') ->retry(5, 'exponential', 120) ->callback('https://myapp.com/callbacks/callmelater') ->send(); // Using presets CallMeLater::http('https://api.example.com/reminder') ->post() ->at('tomorrow') // or 'next_monday', 'next_week', etc. ->send(); ``` ## Reminders ```php // Simple CallMeLater::reminder('Approve deployment') ->to('manager@example.com') ->message('Please approve the production deployment') ->at('tomorrow 9am') ->send(); // With all options CallMeLater::reminder('Weekly report sign-off') ->to('cfo@example.com') ->toMany(['ceo@example.com', 'coo@example.com']) ->message('Please review the weekly financial report') ->buttons('Approve', 'Reject') ->allowSnooze(3) ->requireAll() ->expiresInDays(7) ->escalateTo(['backup@example.com'], afterHours: 48) ->attach('https://example.com/report.pdf', 'Weekly Report') ->callback('https://myapp.com/callbacks/response') ->inDays(1) ->send(); ``` ## Chains ```php CallMeLater::chain('Process Order') ->input(['order_id' => 456]) ->addHttpStep('Charge Payment') ->url('https://api.stripe.com/v1/charges') ->post() ->body(['amount' => 2999]) ->maxAttempts(3) ->done() ->addGateStep('Approve Shipping') ->message('Approve shipment for order #456?') ->to('warehouse@example.com') ->timeout('2d') ->onTimeout('cancel') ->done() ->addDelayStep('Wait 1 hour') ->hours(1) ->done() ->addHttpStep('Ship Order') ->url('https://shipping.example.com/ship') ->post() ->body(['order_id' => '{{input.order_id}}']) ->condition("{{steps.1.response.action}} == confirmed") ->done() ->errorHandling('fail_chain') ->send(); ``` ## Templates ```php // Create $tpl = CallMeLater::template('Invoice Reminder') ->description('Sends reminder to approve an invoice') ->mode('approval') ->gateConfig([ 'message' => 'Please approve invoice #{{invoice_id}}', 'recipients' => ['email:{{approver_email}}'], ]) ->placeholder('invoice_id', required: true, description: 'The invoice number') ->placeholder('approver_email', required: true) ->send(); // Trigger (no auth needed) CallMeLater::trigger($tpl['trigger_token'], [ 'invoice_id' => 'INV-001', 'approver_email' => 'boss@example.com', ]); // Management CallMeLater::getTemplate('tpl_123'); CallMeLater::listTemplates(); CallMeLater::deleteTemplate('tpl_123'); CallMeLater::toggleTemplate('tpl_123'); CallMeLater::regenerateTemplateToken('tpl_123'); CallMeLater::templateLimits(); ``` ## Managing Actions ```php $action = CallMeLater::get('action_id'); $actions = CallMeLater::list(['status' => 'scheduled', 'per_page' => 50]); CallMeLater::cancel('action_id'); $chain = CallMeLater::getChain('chn_123'); $chains = CallMeLater::listChains(['status' => 'running']); CallMeLater::cancelChain('chn_123'); ``` ## Webhooks Verify signatures and dispatch Laravel events automatically: ```php // routes/web.php Route::post('/webhooks/callmelater', function (Illuminate\Http\Request $request) { CallMeLater::webhooks()->handle($request); return response()->json(['received' => true]); }); ``` Or use the middleware: ```php use CallMeLater\Laravel\Http\Middleware\VerifyCallMeLaterSignature; Route::post('/webhooks/callmelater', [WebhookController::class, 'handle']) ->middleware(VerifyCallMeLaterSignature::class); ``` ### Listening to events ```php // EventServiceProvider protected $listen = [ \CallMeLater\Laravel\Events\ReminderResponded::class => [ \App\Listeners\HandleReminderResponse::class, ], ]; ``` ```php // app/Listeners/HandleReminderResponse.php use CallMeLater\Laravel\Events\ReminderResponded; class HandleReminderResponse { public function handle(ReminderResponded $event): void { if ($event->isConfirmed()) { logger()->info("Approved by {$event->responderEmail}"); } elseif ($event->isDeclined()) { logger()->warning("Declined: {$event->actionName}"); } } } ``` Events: `ActionExecuted`, `ActionFailed`, `ActionExpired`, `ReminderResponded`. ### Skip verification (local dev) ```php CallMeLater::webhooks()->skipVerification()->handle($request); $payload = CallMeLater::webhooks()->withoutEvents()->handle($request); ``` ## Artisan Commands ```bash php artisan callmelater:list php artisan callmelater:list --status=scheduled --type=webhook php artisan callmelater:cancel action_id_here ``` ## Dependency Injection ```php use CallMeLater\Laravel\CallMeLater; class OrderController extends Controller { public function __construct(private CallMeLater $callMeLater) {} public function scheduleFollowUp(Order $order) { $this->callMeLater->reminder("Follow up on order #{$order->id}") ->to($order->customer_email) ->message("How was your experience?") ->inDays(7) ->send(); } } ``` ## Debugging ```php // Inspect payload without sending $payload = CallMeLater::http('https://example.com') ->post() ->inHours(2) ->toArray(); // Dump and die CallMeLater::reminder('Test')->to('user@example.com')->dd(); ``` ## Error Handling ```php use CallMeLater\Laravel\Exceptions\ApiException; use CallMeLater\Laravel\Exceptions\AuthenticationException; try { CallMeLater::http('https://example.com')->send(); } catch (AuthenticationException $e) { // Invalid or expired API token } catch (ApiException $e) { $e->getStatusCode(); // 422, 404, etc. $e->getValidationErrors(); // ['field' => ['message']] $e->getResponseBody(); // Raw response } ``` --- ## n8n Integration Use CallMeLater in n8n visual workflows with the community node package. ## Installation ### n8n Desktop / Cloud 1. Go to **Settings** → **Community Nodes** 2. Click **Install a community node** 3. Enter `n8n-nodes-callmelater` 4. Click **Install** ### Self-hosted n8n ```bash npm install n8n-nodes-callmelater ``` Restart your n8n instance after installing. ## Credentials Setup 1. Sign up at [callmelater.io](https://callmelater.io) 2. Go to **Settings → API Tokens** and create a token with `read` + `write` scopes 3. In n8n, create **CallMeLater API** credentials with your token ## Nodes ### CallMeLater (Action Node) | Operation | Description | |-----------|-------------| | Create Webhook | Schedule an HTTP request for later | | Create Approval | Send an approval request to recipients | | Get Action | Retrieve details of an action | | Cancel Action | Cancel a scheduled action | ### CallMeLater Trigger Starts your workflow when CallMeLater events occur: | Event | Description | |-------|-------------| | Reminder Responded | Someone confirmed, declined, or snoozed | | Action Executed | A webhook completed successfully | | Action Failed | A webhook failed after all retries | | Action Expired | A reminder expired without response | ## Example Workflows ### Deployment approval ``` GitHub Trigger → Build & Test → CallMeLater (Create Approval) → CallMeLater Trigger → Deploy ``` 1. GitHub push triggers the workflow 2. Build and test steps run 3. CallMeLater sends approval request to the ops team 4. When someone approves, the trigger fires and deployment continues ### Scheduled follow-up ``` New Customer → CallMeLater (Wait 3 days) → Send Welcome Email ``` ### Invoice reminder with escalation ``` Invoice Created → CallMeLater (Wait 7 days) → Check Payment → If Unpaid: CallMeLater (Approval to Manager) ``` ## Webhook Setup For the **CallMeLater Trigger** to receive events: 1. Create a workflow with the CallMeLater Trigger node 2. Copy the **Webhook URL** shown in the node 3. When creating actions via the CallMeLater node, paste this URL as the **Callback URL** ### Signature verification 1. Generate a random secret string 2. Set it as `webhook_secret` when creating actions 3. Enter the same secret in the CallMeLater Trigger node's **Webhook Secret** field --- ## Node.js SDK Official TypeScript SDK for CallMeLater. Zero dependencies, ESM + CJS. ## Installation ```bash npm install callmelater ``` Requires Node.js 18+ (uses native `fetch`). ## Configuration ```ts const client = new CallMeLater({ apiToken: 'sk_live_...', // Required apiUrl: 'https://callmelater.io', // Optional (default) webhookSecret: 'whsec_...', // For webhook signature verification timezone: 'America/New_York', // Default timezone retry: { // Default retry config maxAttempts: 3, backoff: 'exponential', initialDelay: 60, }, }); ``` ## HTTP Actions ```ts const action = await client.http('https://api.example.com/webhook') .post() .name('Process Order #123') .headers({ 'X-Api-Key': 'secret' }) .payload({ order_id: 123 }) .inMinutes(30) .retry(5, 'exponential', 120) .callback('https://myapp.com/webhook') .send(); console.log(action.id); // 'act_...' ``` ### Scheduling options ```ts // Relative delay .inMinutes(30) .inHours(2) .inDays(7) .delay(5, 'minutes') // or 'hours', 'days', 'weeks' // Presets .at('tomorrow') .at('next_monday') .at('next_week') // Specific datetime .at('2025-06-15 14:30:00') .at(new Date(2025, 5, 15, 14, 30)) // Timezone .timezone('America/New_York') ``` ## Reminders ```ts await client.reminder('Approve deployment') .to('manager@example.com') .message('Please approve the production deployment') .buttons('Approve', 'Reject') .allowSnooze(3) .expiresInDays(7) .inHours(1) .callback('https://myapp.com/webhook') .send(); ``` ### Recipients ```ts .to('user@example.com') // Email .toMany(['alice@co.com', 'bob@co.com']) // Multiple emails .toPhone('+1234567890') // SMS .toChannel('channel-uuid') // Teams/Slack channel ``` ### Options ```ts .buttons('Yes', 'No') // Button labels .allowSnooze(3) // Max snoozes (0 to disable) .noSnooze() // Disable snoozing .requireAll() // All recipients must respond .firstResponse() // Complete on first response .expiresInDays(14) // Token expiry .escalateTo(['boss@co.com'], 24) // Escalate after N hours .attach('https://example.com/report.pdf', 'Report') ``` ## Chains Multi-step workflows with HTTP calls, approvals, and delays: ```ts await client.chain('Process Order') .input({ order_id: 42 }) .errorHandling('fail_chain') .addHttpStep('Validate') .url('https://api.example.com/validate') .post() .body({ order_id: '{{input.order_id}}' }) .done() .addGateStep('Manager Approval') .message('Approve order #{{input.order_id}}?') .to('manager@example.com') .timeout('24h') .onTimeout('cancel') .done() .addDelayStep('Cooling Period') .hours(1) .done() .addHttpStep('Execute') .url('https://api.example.com/execute') .post() .condition('{{steps.2.response.action}} == confirmed') .done() .send(); ``` ## Templates Reusable action configs with trigger URLs (no API key needed to trigger): ```ts // Create const template = await client.template('Process Order') .description('Template for order processing') .type('action') .mode('webhook') .requestConfig({ url: 'https://api.example.com/process', method: 'POST', body: { order_id: '{{order_id}}' }, }) .placeholder('order_id', true, 'The order ID') .send(); // Trigger (public, no auth) await client.trigger(template.trigger_token, { order_id: 'ORD-123', }); ``` ### Management ```ts await client.getTemplate(id); await client.listTemplates(); await client.deleteTemplate(id); await client.toggleTemplate(id); // Enable/disable await client.regenerateTemplateToken(id); await client.templateLimits(); ``` ## Actions & Chains CRUD ```ts await client.getAction(id); await client.listActions({ status: 'scheduled' }); await client.cancelAction(id); await client.getChain(id); await client.listChains({ limit: '10' }); await client.cancelChain(id); ``` ## Webhooks Handle incoming events with signature verification: ```ts app.post('/webhooks/callmelater', express.raw({ type: 'application/json' }), (req, res) => { const handler = client.webhooks(); try { const event = handler.handle( req.body.toString(), req.headers['x-callmelater-signature'] as string, ); console.log('Event:', event.event, event.action_id); res.sendStatus(200); } catch (err) { console.error('Webhook error:', err); res.sendStatus(400); } }); ``` ### Event emitter ```ts const handler = client.webhooks(); handler.on('action.executed', (e) => console.log('Executed:', e.action_id)); handler.on('action.failed', (e) => console.log('Failed:', e.action_id)); handler.on('reminder.responded', (e) => console.log('Response:', e.payload.response)); ``` ## Error Handling ```ts try { await client.getAction('nonexistent'); } catch (err) { if (err instanceof ApiError) { console.log(err.statusCode); // 404 console.log(err.validationErrors); // { field: ['message'] } console.log(err.responseBody); } } ``` ## Debugging Inspect payloads without sending: ```ts const payload = client.http('https://example.com') .post() .payload({ test: true }) .inMinutes(5) .toJSON(); console.log(JSON.stringify(payload, null, 2)); ``` --- ## Actions & Scheduling Everything in CallMeLater is an **action** -- something scheduled to happen in the future. Actions operate in one of two modes: **webhook** or **approval**. ## Webhook mode Webhook mode is the default. A webhook action delivers an HTTP request to your endpoint at a scheduled time. ```json { "schedule": { "preset": "tomorrow" }, "request": { "method": "POST", "url": "https://api.example.com/webhook", "headers": { "X-Custom-Header": "value" }, "body": { "event": "trial_expired", "user_id": 42 } } } ``` Use webhook mode for trial expirations, delayed notifications, scheduled API calls, cleanup tasks, and follow-up triggers. ## Approval mode An approval action sends an interactive message to one or more recipients and collects their response (confirm, decline, or snooze). ```json { "mode": "approval", "schedule": { "wait": "2h" }, "gate": { "message": "Please confirm the deployment", "recipients": ["ops@example.com", "+1234567890"], "confirmation_mode": "first_response" }, "callback_url": "https://api.example.com/webhooks/response" } ``` :::info Approvals do not automatically execute anything. When someone responds, CallMeLater sends the response to your `callback_url`. Your system decides what to do next. ::: Use approval mode for approval workflows, human confirmations, check-ins, and escalation chains. ## Scheduling Every action needs a **schedule** that defines when it should fire. There are three ways to set it. ### Presets Named time references, resolved relative to now (or to a timezone if provided): | Preset | When | |--------|------| | `tomorrow` | Tomorrow at the current time | | `next_monday` | Next Monday at the current time | | `next_tuesday` | Next Tuesday at the current time | | `next_wednesday` | Next Wednesday at the current time | | `next_thursday` | Next Thursday at the current time | | `next_friday` | Next Friday at the current time | | `next_saturday` | Next Saturday at the current time | | `next_sunday` | Next Sunday at the current time | | `next_week` | Next Monday at the current time | | `1h`, `2h`, `4h` | 1, 2, or 4 hours from now | | `1d`, `3d` | 1 or 3 days from now | | `1w` | 1 week from now | ```json { "schedule": { "preset": "next_monday", "timezone": "America/New_York" } } ``` ### Relative wait A duration from now using `schedule.wait`: ```json { "schedule": { "wait": "30m" } } ``` | Format | Meaning | Example | |--------|---------|---------| | `Nm` | N minutes | `5m` = 5 minutes | | `Nh` | N hours | `2h` = 2 hours | | `Nd` | N days | `1d` = 1 day | | `Nw` | N weeks | `1w` = 1 week | ### Exact time An ISO 8601 UTC timestamp using `scheduled_for`: ```json { "scheduled_for": "2026-04-01T14:30:00Z" } ``` ### Timezone Optional. Defaults to **UTC**. Provide a timezone when using presets so that times like `tomorrow` and `next_monday` resolve to the correct local time. ```json { "schedule": { "preset": "tomorrow", "timezone": "Europe/Paris" } } ``` ## Lifecycle states Actions move through a series of states from creation to completion. **Webhook mode:** ``` scheduled → resolved → executing → executed ↘ failed ``` **Approval mode:** ``` scheduled → resolved → executing → awaiting_response → executed ↘ failed ↘ expired ``` Any non-terminal action can also be `cancelled`. | State | Description | Next states | |-------|-------------|-------------| | `scheduled` | Schedule is being resolved to an exact timestamp | `resolved`, `cancelled` | | `resolved` | Waiting for the scheduled time to arrive | `executing`, `cancelled` | | `executing` | HTTP request or reminder is being delivered | `executed`, `failed`, `awaiting_response`, `resolved` (retry) | | `awaiting_response` | Reminder sent, waiting for a human reply | `executed`, `failed`, `expired`, `cancelled`, `scheduled` (snooze) | | `executed` | Completed successfully (terminal) | -- | | `failed` | Failed permanently (terminal) | `resolved` (manual retry) | | `expired` | Approval timed out without response (terminal) | -- | | `cancelled` | Cancelled before completion (terminal) | -- | ## Idempotency keys Use `idempotency_key` to prevent duplicate actions when your system retries requests or a user double-clicks. ```json { "idempotency_key": "trial-end-user-42", "schedule": { "wait": "14d" }, "request": { "url": "https://api.example.com/expire" } } ``` Key format patterns: ``` trial:user:123 deploy:v2.1 invoice:1234:reminder weekly-report:2026-W07 ``` Keys are scoped per account, up to 255 characters, and case-sensitive. **Behavior when a matching key already exists:** | Existing action state | Behavior | |-----------------------|----------| | `scheduled` | Returns existing action | | `resolved` | Returns existing action | | `awaiting_response` | Returns existing action | | `executed` | Creates new action | | `failed` | Creates new action | | `cancelled` | Creates new action | Non-terminal states return the existing action to prevent duplicates. Terminal states allow key reuse since the original action is already complete. ## Dedup keys Dedup keys group related actions together and control how they interact. Unlike idempotency keys (which prevent duplicates of the same request), dedup keys coordinate behavior across different actions that share a logical group. ```json { "dedup_keys": ["deploy:api-service"], "coordination": { "on_create": "cancel_and_replace" }, "schedule": { "wait": "1h" }, "request": { "url": "https://ci.example.com/deploy" } } ``` ### `on_create` behaviors Controls what happens when you create a new action with the same dedup key as an existing non-terminal action. | Behavior | Description | |----------|-------------| | `skip_if_exists` | Return the existing action instead of creating a new one (response includes `meta.skipped: true`) | | `cancel_and_replace` | Cancel all existing non-terminal actions with matching keys, then create the new action | ### `on_execute` behaviors Controls what happens at execution time based on other actions sharing the same dedup key. This is a nested object with condition-based logic: ```json { "dedup_keys": ["deploy:api-service"], "coordination": { "on_execute": { "condition": "skip_if_previous_pending", "on_condition_not_met": "cancel", "reschedule_delay": 300, "max_reschedules": 10 } } } ``` | Field | Description | |-------|-------------| | `condition` | `skip_if_previous_pending`, `execute_if_previous_failed`, `execute_if_previous_succeeded`, or `wait_for_previous` | | `on_condition_not_met` | `cancel`, `reschedule`, or `fail` | | `reschedule_delay` | Seconds to wait before retrying when rescheduled (used with `reschedule`) | | `max_reschedules` | Maximum number of reschedule attempts (used with `reschedule`) | ### Format rules - Alphanumeric characters plus `_`, `:`, `.`, `-` - No spaces, slashes, or special characters - Case-sensitive (`Deploy:API` and `deploy:api` are different keys) - Maximum 10 keys per action - Scoped to your account **Valid:** `deploy:production`, `user:42:notifications`, `workflow.onboarding.step-1` **Invalid:** `key with spaces`, `key/with/slashes`, `key@email.com` --- ## Approvals & Reminders Approval actions send interactive messages to humans and collect their responses. When someone responds, CallMeLater notifies your system -- your code decides what happens next. ## How it works 1. **Create action** -- You create an approval action with a message, recipients, and a `callback_url`. 2. **CallMeLater sends reminder** -- At the scheduled time, CallMeLater delivers the reminder via email or SMS. 3. **Recipient responds** -- The recipient clicks Confirm, Decline, or Snooze directly from the message (no login required). 4. **CallMeLater calls your callback** -- CallMeLater sends the response details to your `callback_url`. Your system decides what to do. :::info Approvals don't automatically execute anything. When someone responds, CallMeLater sends the response to your callback URL. Your system decides what to do next. ::: ## Recipients Recipients are auto-detected by format: | Format | Channel | Example | |--------|---------|---------| | Email address | Email | `ops@example.com` | | E.164 phone number | SMS | `+1234567890` | | Channel reference | Channel | `channel:uuid` | You can mix formats in the same action: ```json { "gate": { "recipients": ["ops@example.com", "+1234567890"] } } ``` ## Confirmation modes | Mode | Behavior | |------|----------| | `first_response` (default) | Completes as soon as any single recipient responds | | `all_required` | Waits until every recipient has responded | Use `first_response` for general notifications where one confirmation is enough. Use `all_required` for critical approvals that need multiple sign-offs. ```json { "gate": { "confirmation_mode": "all_required", "recipients": ["cto@example.com", "security@example.com"] } } ``` ## Response options | Action | Description | |--------|-------------| | **Confirm** | Marks the action as `executed` and sends response details to your callback URL | | **Decline** | Marks the action as `failed` and sends response details to your callback URL | | **Snooze** | Reschedules the reminder to be sent again after a configurable period | ## Snooze Control how many times recipients can snooze with `max_snoozes`: ```json { "gate": { "max_snoozes": 3 } } ``` - Default: `5` - Set to `0` to disable snoozing entirely (hides the snooze button) - When the snooze limit is reached, the snooze option disappears from subsequent reminders ## Token expiry Response links expire after a configurable duration. Once expired, the action moves to `expired` status and the links stop working. ```json { "gate": { "timeout": "7d" } } ``` - Default: `7d` (7 days) - Configurable per action via `gate.timeout` (e.g., `"3d"`, `"12h"`, `"7d"`) ## Escalation If nobody responds within a time window, CallMeLater can automatically notify escalation contacts. ```json { "gate": { "message": "Approve the production deployment", "recipients": ["team@example.com"], "escalation_contacts": ["manager@example.com"], "escalation_after_hours": 4 } } ``` - `escalation_contacts` -- array of email addresses or phone numbers to notify if no response is received - `escalation_after_hours` -- hours to wait before escalating (minimum 0.5) - If a contact has no prefix (like `channel:`), email is assumed automatically Escalation is about getting human attention when the original recipients are unresponsive. It is separate from retries, which handle technical delivery failures. ## Team members Instead of using raw email addresses and phone numbers in your API calls, you can create contacts in **Settings > Team Members** and reference them by member ID: ```json { "gate": { "recipients": ["member-uuid-1", "member-uuid-2"] } } ``` CallMeLater looks up each member's contact details and sends to the appropriate channel. Responses are tracked with team member attribution. --- ## Retries, Errors & Callbacks CallMeLater automatically retries failed webhook deliveries and notifies you of outcomes via callbacks. This page covers how retries work, how to configure them, and how to receive lifecycle notifications. ## What triggers retries | Response | Behavior | |----------|----------| | 2xx | Success -- no retry | | 4xx (except 429) | Permanent failure -- no retry | | 429 | Retry (rate limited) | | 5xx | Retry (server error) | | Timeout (30s) | Retry | | Connection error | Retry | A `4xx` response (other than 429) is treated as a permanent failure because it typically indicates a problem with the request itself, not a transient issue. Fix the request rather than retrying. ## Retry strategies ### Exponential backoff (default) Delays increase between each attempt, giving transient issues time to resolve: | Attempt | Wait before attempt | Cumulative | |---------|---------------------|------------| | 1 | Immediate | 0 | | 2 | 1 minute | 1 min | | 3 | 5 minutes | 6 min | | 4 | 15 minutes | 21 min | | 5 | 1 hour | 1h 21m | Total window: approximately 1 hour 21 minutes from first attempt to last. If `max_attempts` is increased beyond 5, attempt 6 would wait 4 hours. ```json { "retry_strategy": "exponential", "max_attempts": 5 } ``` ### Linear Delays increase linearly using the formula `300 × attempt_count` seconds. Each subsequent retry waits longer than the previous one: 5 minutes, 10 minutes, 15 minutes, 20 minutes, and so on. ```json { "retry_strategy": "linear", "max_attempts": 5 } ``` ## Configuration ### `max_attempts` Controls the maximum number of delivery attempts (including the first). Default: `5`. ```json { "max_attempts": 10 } ``` The maximum value depends on your plan. When all attempts are exhausted without a successful delivery, the action moves to `failed` status. ## Callbacks Callbacks let you track action lifecycle events without polling. When an action completes, fails, or expires, CallMeLater sends an HTTP POST to your `callback_url`. ### Enabling callbacks Set `callback_url` when creating an action: ```json { "schedule": { "wait": "2h" }, "request": { "url": "https://api.example.com/process" }, "callback_url": "https://your-app.com/webhooks/callmelater" } ``` ### Events | Event | When it fires | |-------|---------------| | `action.executed` | Webhook delivered successfully (2xx response received) | | `action.failed` | All retry attempts exhausted or permanent failure | | `action.expired` | Approval timed out without a response | | `reminder.responded` | Someone responded to an approval (confirm, decline, or snooze) | ### Payload ```json { "event": "action.executed", "action_id": "abc123-def456", "action_name": "Sync inventory", "timestamp": "2026-01-15T10:30:00Z", "payload": { "status": "executed", "response_code": 200, "duration_ms": 150, "attempt_number": 1 } } ``` For `action.failed`: ```json { "event": "action.failed", "action_id": "abc123-def456", "action_name": "Sync inventory", "timestamp": "2026-01-15T10:30:00Z", "payload": { "status": "failed", "response_code": 500, "total_attempts": 6, "error_message": "Service Unavailable" } } ``` For `reminder.responded`: ```json { "event": "reminder.responded", "action_id": "abc123-def456", "action_name": "Deploy approval", "timestamp": "2026-01-15T10:30:00Z", "payload": { "status": "executed", "response": "confirm", "respondent": "ops@example.com" } } ``` ### Callback retries If your callback endpoint fails to respond, CallMeLater retries up to **3 attempts** with exponential backoff (immediate, 1 minute, 5 minutes). After 3 failed attempts, the callback is abandoned. This does not affect the action's status. ## Making webhooks resilient Follow these practices to build reliable webhook handlers: - **Return 200 quickly, process async.** Acknowledge receipt immediately and handle business logic in the background. Requests time out after 30 seconds. - **Handle duplicates.** The same webhook may be delivered more than once. Use the action ID as a dedup key in your handler to avoid processing the same event twice. - **Use appropriate status codes.** Return `200` for success, `500` if you want CallMeLater to retry, and `400` if the request is invalid and should not be retried. - **Verify webhook signatures.** Validate the `X-CallMeLater-Signature` header to confirm requests originate from CallMeLater. See [Security](/reference/security) for implementation examples. --- ## Chains & Workflows Chains let you compose multi-step workflows where each step can be an HTTP webhook, a human approval gate, or a timed wait. Steps execute sequentially, pass data forward, and handle failures as a unit. ## When to Use Chains Use chains when you need steps that **depend on each other**. A chain shares context between steps -- the HTTP response from step 1 is available as a variable in step 3 -- and treats the whole sequence as a single unit for error handling. Use **individual actions** when steps are independent and do not need shared state. For example, scheduling three unrelated webhooks is simpler as three separate actions. | Feature | Individual Actions | Chains | |---------|-------------------|--------| | Steps depend on each other | No | Yes | | Data passed between steps | No | Yes (`{{steps.N.response.*}}`) | | Fail/cancel as a unit | No | Yes | | Human approval mid-workflow | Separate action | Built-in gate step | --- ## Step Types | Type | API Request Value | API Response Alias | Description | Use For | |------|-------------------|-------------------|-------------|---------| | **HTTP** | `http_call` | `webhook` | Makes an HTTP request and captures the response | API calls, webhooks, data processing | | **Approval** | `gated` | `approval` | Sends a message to recipients and waits for a human response | Sign-offs, reviews, confirmations | | **Wait** | `delay` | `wait` | Pauses the chain for a specified duration | Cooling periods, rate limiting, delays between steps | In API requests, use `http_call`, `gated`, `delay`. API responses return the aliases `webhook`, `approval`, `wait`. --- ## Building a Chain Here is a complete expense approval workflow with four steps: validate the expense, ask a manager to approve it, wait for a cooling period, then process the payment. ```ts const client = new CallMeLater({ apiToken: 'sk_live_...' }); await client.chain('Expense Approval') .input({ expense_id: 'exp_42', amount: 450.00, submitter: 'alice@example.com' }) .errorHandling('fail_chain') // Step 0: Validate the expense .addHttpStep('Validate Expense') .url('https://api.example.com/expenses/validate') .post() .body({ expense_id: '{{input.expense_id}}', amount: '{{input.amount}}' }) .maxAttempts(3) .done() // Step 1: Manager approval .addGateStep('Manager Approval') .message('Approve expense of ${{input.amount}} submitted by {{input.submitter}}?') .to('manager@example.com') .timeout('48h') .onTimeout('cancel') .done() // Step 2: Cooling period .addDelayStep('Cooling Period') .hours(1) .done() // Step 3: Process payment (only if approved) .addHttpStep('Process Payment') .url('https://api.example.com/payments') .post() .body({ expense_id: '{{input.expense_id}}', validation_ref: '{{steps.0.response.reference}}' }) .condition("{{steps.1.status}} == confirmed") .done() .send(); ``` ```php use CallMeLater\Laravel\Facades\CallMeLater; CallMeLater::chain('Expense Approval') ->input(['expense_id' => 'exp_42', 'amount' => 450.00, 'submitter' => 'alice@example.com']) ->errorHandling('fail_chain') // Step 0: Validate the expense ->addHttpStep('Validate Expense') ->url('https://api.example.com/expenses/validate') ->post() ->body([ 'expense_id' => '{{input.expense_id}}', 'amount' => '{{input.amount}}', ]) ->maxAttempts(3) ->done() // Step 1: Manager approval ->addGateStep('Manager Approval') ->message('Approve expense of ${{input.amount}} submitted by {{input.submitter}}?') ->to('manager@example.com') ->timeout('48h') ->onTimeout('cancel') ->done() // Step 2: Cooling period ->addDelayStep('Cooling Period') ->hours(1) ->done() // Step 3: Process payment (only if approved) ->addHttpStep('Process Payment') ->url('https://api.example.com/payments') ->post() ->body([ 'expense_id' => '{{input.expense_id}}', 'validation_ref' => '{{steps.0.response.reference}}', ]) ->condition('{{steps.1.status}} == confirmed') ->done() ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/chains \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Expense Approval", "error_handling": "fail_chain", "input": { "expense_id": "exp_42", "amount": 450.00, "submitter": "alice@example.com" }, "steps": [ { "name": "Validate Expense", "type": "http_call", "url": "https://api.example.com/expenses/validate", "method": "POST", "body": { "expense_id": "{{input.expense_id}}", "amount": "{{input.amount}}" }, "max_attempts": 3 }, { "name": "Manager Approval", "type": "gated", "gate": { "message": "Approve expense of ${{input.amount}} submitted by {{input.submitter}}?", "recipients": ["manager@example.com"], "timeout": "48h", "on_timeout": "cancel" } }, { "name": "Cooling Period", "type": "delay", "delay": "1h" }, { "name": "Process Payment", "type": "http_call", "url": "https://api.example.com/payments", "method": "POST", "body": { "expense_id": "{{input.expense_id}}", "validation_ref": "{{steps.0.response.reference}}" }, "condition": "{{steps.1.status}} == confirmed" } ] }' ``` --- ## Variable Interpolation Chains support `{{...}}` expressions in step URLs, headers, bodies, and gate messages. Variables are resolved at the moment each step executes, so later steps can reference results from earlier ones. ### Input variables Access values from the chain's `input` object: ``` {{input.expense_id}} -> "exp_42" {{input.amount}} -> 450.00 {{input.submitter}} -> "alice@example.com" ``` ### Previous step responses Access the HTTP response body from a completed webhook step. Steps are zero-indexed: ``` {{steps.0.response.reference}} -> value from step 0's JSON response {{steps.0.response.data.id}} -> nested field access {{steps.2.response.total}} -> value from step 2's response ``` ### Step status Check what happened in a previous step: ``` {{steps.0.status}} -> "executed", "failed", "skipped" {{steps.1.status}} -> "confirmed", "declined" (for approval steps) ``` **Status values by step type:** | Step Type | Possible Statuses | |-----------|------------------| | HTTP (webhook) | `executed`, `failed`, `skipped` | | Approval | `confirmed`, `declined`, `skipped` | | Wait | `executed`, `skipped` | --- ## Conditions Add a `condition` to any step to make it execute only when the expression evaluates to true. If the condition is not met, the step is marked as `skipped` and the chain continues. ### Operators | Operator | Description | Example | |----------|-------------|---------| | `==` | Equal | `{{steps.1.status}} == confirmed` | | `!=` | Not equal | `{{steps.0.status}} != failed` | | `contains` | String contains | `{{steps.0.response.role}} contains admin` | | `not_contains` | String does not contain | `{{steps.0.response.tags}} not_contains deprecated` | | `starts_with` | Starts with | `{{steps.0.response.env}} starts_with prod` | | `ends_with` | Ends with | `{{steps.0.response.email}} ends_with @example.com` | | `>` | Greater than | `{{steps.0.response.total}} > 1000` | | `<` | Less than | `{{steps.0.response.priority}} < 3` | | `>=` | Greater than or equal | `{{steps.0.response.score}} >= 80` | | `<=` | Less than or equal | `{{steps.0.response.risk}} <= 5` | ### Examples ``` // Only process payment if manager approved {{steps.1.status}} == confirmed // Skip notification for low-value orders {{steps.0.response.total}} > 100 // Execute only if the validation response contains "approved" {{steps.0.response.result}} == approved ``` --- ## Error Handling Set the `error_handling` field on the chain to control what happens when a step fails. ### `fail_chain` (default) The entire chain stops immediately. No further steps execute. The chain status becomes `failed`. ```json { "error_handling": "fail_chain" } ``` Use this for workflows where every step is critical -- if payment validation fails, you do not want to proceed to approval. ### `skip_step` The failed step is marked as `skipped` and the chain continues to the next step. Use this for workflows with optional steps. ```json { "error_handling": "skip_step" } ``` Use this when some steps are nice-to-have. For example, a notification step that fails should not block a payment. :::tip Combine `skip_step` with conditions for fine-grained control. Even with `skip_step` enabled, you can use a condition on a later step to check whether a critical earlier step succeeded: ``` "condition": "{{steps.0.status}} == executed" ``` ::: --- ## Chain Statuses | Status | Description | |--------|-------------| | `pending` | Chain created, first step has not started | | `running` | At least one step has started executing | | `completed` | All steps finished (executed, confirmed, or skipped) | | `failed` | A step failed and `error_handling` is `fail_chain` | | `cancelled` | Chain was cancelled via the API | --- ## Managing Chains ```ts // Get chain details const chain = await client.getChain('chn_abc123'); console.log(chain.status, chain.current_step); // List chains const chains = await client.listChains({ status: 'running' }); // Cancel a chain (cancels all pending steps) await client.cancelChain('chn_abc123'); ``` ```php // Get chain details $chain = CallMeLater::getChain('chn_abc123'); // List chains $chains = CallMeLater::listChains(['status' => 'running']); // Cancel a chain CallMeLater::cancelChain('chn_abc123'); ``` ```bash # Get chain curl https://callmelater.io/api/v1/chains/chn_abc123 \ -H "Authorization: Bearer sk_live_..." # List running chains curl "https://callmelater.io/api/v1/chains?status=running" \ -H "Authorization: Bearer sk_live_..." # Cancel chain curl -X DELETE https://callmelater.io/api/v1/chains/chn_abc123 \ -H "Authorization: Bearer sk_live_..." ``` :::note Cancelling a chain cancels all pending steps. Steps that have already executed are not rolled back. ::: --- ## Common Patterns Practical, copy-paste-ready patterns for the most frequent CallMeLater use cases. Each example shows the SDK code alongside the equivalent curl request. --- ## Trial Expiration Schedule a webhook to fire when a user's trial ends. If they subscribe before the timer runs out, cancel it by idempotency key. **Schedule the expiration webhook:** ```ts const client = new CallMeLater({ apiToken: 'sk_live_...' }); const action = await client.http('https://api.example.com/subscriptions/expire') .post() .payload({ user_id: 123, action: 'downgrade_to_free' }) .inDays(14) .idempotencyKey('trial:user:123') .send(); console.log(action.id); // act_... ``` ```php use CallMeLater\Laravel\Facades\CallMeLater; CallMeLater::http('https://api.example.com/subscriptions/expire') ->post() ->payload(['user_id' => 123, 'action' => 'downgrade_to_free']) ->inDays(14) ->idempotencyKey('trial:user:123') ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "webhook", "idempotency_key": "trial:user:123", "schedule": { "wait": "14d" }, "request": { "method": "POST", "url": "https://api.example.com/subscriptions/expire", "body": { "user_id": 123, "action": "downgrade_to_free" } }, "max_attempts": 5 }' ``` **Cancel if the user subscribes:** ```ts await client.cancelAction({ idempotency_key: 'trial:user:123' }); ``` ```php // Cancel by idempotency key via the API $client = app(\CallMeLater\Laravel\CallMeLater::class); $client->cancel('trial:user:123'); // pass idempotency key ``` ```bash curl -X DELETE https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "idempotency_key": "trial:user:123" }' ``` **Tips:** Use `max_attempts: 5` -- this webhook matters. Make your endpoint idempotent so duplicate deliveries are harmless. --- ## Deployment Approval Get human sign-off before deploying to production. The approval is sent immediately and the callback URL receives the response. ```ts await client.reminder('Production deploy v2.4.1') .to('tech-lead@example.com') .toMany(['devops@example.com']) .message('Approve deployment of v2.4.1 to production?') .buttons('Approve', 'Reject') .noSnooze() .expiresInDays(1) .escalateTo(['cto@example.com'], 2) .inMinutes(0) .callback('https://ci.example.com/webhooks/approval') .idempotencyKey('deploy:v2.4.1:prod') .send(); ``` ```php CallMeLater::reminder('Production deploy v2.4.1') ->to('tech-lead@example.com') ->toMany(['devops@example.com']) ->message('Approve deployment of v2.4.1 to production?') ->buttons('Approve', 'Reject') ->noSnooze() ->expiresInDays(1) ->escalateTo(['cto@example.com'], afterHours: 2) ->inMinutes(0) ->callback('https://ci.example.com/webhooks/approval') ->idempotencyKey('deploy:v2.4.1:prod') ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "approval", "name": "Production deploy v2.4.1", "idempotency_key": "deploy:v2.4.1:prod", "schedule": { "wait": "0m" }, "gate": { "message": "Approve deployment of v2.4.1 to production?", "recipients": ["tech-lead@example.com", "devops@example.com"], "max_snoozes": 0, "timeout": "1d", "escalation": { "contacts": ["cto@example.com"], "after_hours": 2 } }, "callback_url": "https://ci.example.com/webhooks/approval" }' ``` **Handle the callback** to continue (or abort) your CI pipeline based on the response: ```ts // In your callback handler app.post('/webhooks/approval', (req, res) => { const { event, payload } = req.body; if (event === 'reminder.responded' && payload.response === 'confirmed') { triggerDeploy('v2.4.1', 'production'); } res.sendStatus(200); }); ``` --- ## Delayed Cleanup Delete temporary resources (exports, uploads, sandbox data) after a retention period. Schedule the cleanup when you create the resource. ```ts await client.http('https://api.example.com/exports/exp_abc123') .delete() .inDays(30) .idempotencyKey('cleanup:export:exp_abc123') .noRetry() .send(); ``` ```php CallMeLater::http('https://api.example.com/exports/exp_abc123') ->delete() ->inDays(30) ->idempotencyKey('cleanup:export:exp_abc123') ->noRetry() ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "webhook", "idempotency_key": "cleanup:export:exp_abc123", "schedule": { "wait": "30d" }, "request": { "method": "DELETE", "url": "https://api.example.com/exports/exp_abc123" }, "max_attempts": 1 }' ``` **Tips:** Use `noRetry()` / `max_attempts: 1` -- if the resource was already deleted manually, one failed attempt is enough. Include the resource ID in the idempotency key for easy cancellation. --- ## Scheduled Reports Generate a weekly report every Monday morning. When the webhook fires, re-schedule the next one from your callback handler. ```ts await client.http('https://api.example.com/reports/generate') .post() .payload({ type: 'weekly_summary', week: '2026-W08' }) .at('next_monday') .timezone('America/New_York') .idempotencyKey('report:weekly:2026-W08') .callback('https://api.example.com/callbacks/report-done') .send(); ``` ```php CallMeLater::http('https://api.example.com/reports/generate') ->post() ->payload(['type' => 'weekly_summary', 'week' => '2026-W08']) ->at('next_monday') ->timezone('America/New_York') ->idempotencyKey('report:weekly:2026-W08') ->callback('https://api.example.com/callbacks/report-done') ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "webhook", "idempotency_key": "report:weekly:2026-W08", "schedule": { "preset": "next_monday" }, "timezone": "America/New_York", "request": { "method": "POST", "url": "https://api.example.com/reports/generate", "body": { "type": "weekly_summary", "week": "2026-W08" } }, "callback_url": "https://api.example.com/callbacks/report-done" }' ``` **Re-schedule from your callback:** ```ts // When the report webhook completes, schedule the next week app.post('/callbacks/report-done', async (req, res) => { if (req.body.event === 'action.executed') { const nextWeek = getNextWeekString(); // e.g. "2026-W09" await client.http('https://api.example.com/reports/generate') .post() .payload({ type: 'weekly_summary', week: nextWeek }) .at('next_monday') .timezone('America/New_York') .idempotencyKey(`report:weekly:${nextWeek}`) .callback('https://api.example.com/callbacks/report-done') .send(); } res.sendStatus(200); }); ``` **Tips:** Include the week in the idempotency key to prevent duplicates. Set `timezone` so "Monday morning" stays consistent across DST changes. --- ## Follow-Up Sequence Send an onboarding email series at day 1, day 3, and day 7 after signup. Each email gets its own action with a unique idempotency key so you can cancel the remaining ones if the user unsubscribes. ```ts const userId = 123; const steps = [ { days: 1, template: 'welcome', key: 'day1' }, { days: 3, template: 'getting_started', key: 'day3' }, { days: 7, template: 'pro_tips', key: 'day7' }, ]; for (const step of steps) { await client.http('https://api.example.com/emails/send') .post() .payload({ user_id: userId, template: step.template }) .inDays(step.days) .idempotencyKey(`onboard:user:${userId}:${step.key}`) .send(); } ``` ```php $userId = 123; $steps = [ ['days' => 1, 'template' => 'welcome', 'key' => 'day1'], ['days' => 3, 'template' => 'getting_started', 'key' => 'day3'], ['days' => 7, 'template' => 'pro_tips', 'key' => 'day7'], ]; foreach ($steps as $step) { CallMeLater::http('https://api.example.com/emails/send') ->post() ->payload(['user_id' => $userId, 'template' => $step['template']]) ->inDays($step['days']) ->idempotencyKey("onboard:user:{$userId}:{$step['key']}") ->send(); } ``` ```bash # Day 1 - Welcome email curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "idempotency_key": "onboard:user:123:day1", "schedule": { "wait": "1d" }, "request": { "method": "POST", "url": "https://api.example.com/emails/send", "body": { "user_id": 123, "template": "welcome" } } }' # Day 3 - Getting started curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "idempotency_key": "onboard:user:123:day3", "schedule": { "wait": "3d" }, "request": { "method": "POST", "url": "https://api.example.com/emails/send", "body": { "user_id": 123, "template": "getting_started" } } }' # Day 7 - Pro tips curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "idempotency_key": "onboard:user:123:day7", "schedule": { "wait": "7d" }, "request": { "method": "POST", "url": "https://api.example.com/emails/send", "body": { "user_id": 123, "template": "pro_tips" } } }' ``` **Cancel remaining emails if the user unsubscribes:** ```ts // Cancel all pending onboarding emails for a user for (const key of ['day1', 'day3', 'day7']) { await client.cancelAction({ idempotency_key: `onboard:user:${userId}:${key}` }); } ``` --- ## Invoice Reminder with Escalation Remind a customer about an upcoming payment. If they do not respond within 48 hours, escalate to your accounts receivable team. ```ts await client.reminder('Invoice INV-2026-042 payment due') .to('billing@customer.com') .message( 'Invoice INV-2026-042 ($3,200) is due in 5 days. ' + 'Click Confirm to mark as scheduled for payment.' ) .buttons('Confirm Payment', 'Decline') .allowSnooze(2) .expiresInDays(7) .escalateTo(['ar@yourcompany.com', '+15551234567'], 48) .at('2026-02-20T09:00:00') .timezone('America/Chicago') .idempotencyKey('invoice:INV-2026-042:reminder') .callback('https://api.example.com/webhooks/invoice-response') .send(); ``` ```php CallMeLater::reminder('Invoice INV-2026-042 payment due') ->to('billing@customer.com') ->message( 'Invoice INV-2026-042 ($3,200) is due in 5 days. ' . 'Click Confirm to mark as scheduled for payment.' ) ->buttons('Confirm Payment', 'Decline') ->allowSnooze(2) ->expiresInDays(7) ->escalateTo(['ar@yourcompany.com', '+15551234567'], afterHours: 48) ->at('2026-02-20T09:00:00') ->timezone('America/Chicago') ->idempotencyKey('invoice:INV-2026-042:reminder') ->callback('https://api.example.com/webhooks/invoice-response') ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "approval", "name": "Invoice INV-2026-042 payment due", "idempotency_key": "invoice:INV-2026-042:reminder", "scheduled_for": "2026-02-20T09:00:00", "timezone": "America/Chicago", "gate": { "message": "Invoice INV-2026-042 ($3,200) is due in 5 days. Click Confirm to mark as scheduled for payment.", "recipients": ["billing@customer.com"], "max_snoozes": 2, "timeout": "7d", "escalation": { "contacts": ["ar@yourcompany.com", "+15551234567"], "after_hours": 48 } }, "callback_url": "https://api.example.com/webhooks/invoice-response" }' ``` **Tips:** Schedule the reminder a few days before the due date using `scheduled_for`. Set `escalation.after_hours` based on urgency -- 48 hours gives the customer time to respond before your team gets involved. Include a phone number in escalation contacts for SMS notifications. --- ## Templates Templates are reusable action configurations with unique trigger URLs. Once created, anyone can trigger a template with a simple POST request -- no API key required. This makes templates ideal for CI/CD pipelines, external integrations, and shared workflows. ## What Templates Are A template captures an action's configuration (URL, method, body, approval settings, retry policy) and exposes it behind a public trigger URL. When triggered, the template creates a new action (or chain) with the stored configuration, substituting any placeholder values provided in the request. **Key properties:** - Each template gets a unique trigger token and URL (e.g., `https://callmelater.io/t/clmt_abc123...`) - Triggering does not require an API key -- the token is the authentication - Placeholders let you inject dynamic values at trigger time - Templates can create single actions (webhook or approval) or entire chains --- ## Creating a Template This example creates a deployment notification template with placeholders for the service name, version, and environment. ```ts const client = new CallMeLater({ apiToken: 'sk_live_...' }); const template = await client.template('Deploy {{service}}') .description('Triggers a deployment approval workflow') .type('action') .mode('approval') .timezone('America/New_York') .gateConfig({ message: 'Deploy {{service}} v{{version}} to {{env}}?', recipients: ['ops@example.com'], timeout: '4h', on_timeout: 'cancel', }) .requestConfig({ url: 'https://deploy.example.com/{{service}}', method: 'POST', body: { version: '{{version}}', environment: '{{env}}', }, }) .placeholder('service', true, 'Service name to deploy') .placeholder('version', true, 'Semantic version number') .placeholder('env', false, 'Target environment', 'staging') .maxAttempts(3) .retryStrategy('exponential') .send(); console.log(template.trigger_url); // https://callmelater.io/t/clmt_abc123... ``` ```php use CallMeLater\Laravel\Facades\CallMeLater; $template = CallMeLater::template('Deploy {{service}}') ->description('Triggers a deployment approval workflow') ->type('action') ->mode('approval') ->timezone('America/New_York') ->gateConfig([ 'message' => 'Deploy {{service}} v{{version}} to {{env}}?', 'recipients' => ['ops@example.com'], 'timeout' => '4h', 'on_timeout' => 'cancel', ]) ->requestConfig([ 'url' => 'https://deploy.example.com/{{service}}', 'method' => 'POST', 'body' => [ 'version' => '{{version}}', 'environment' => '{{env}}', ], ]) ->placeholder('service', required: true, description: 'Service name to deploy') ->placeholder('version', required: true, description: 'Semantic version number') ->placeholder('env', required: false, description: 'Target environment', default: 'staging') ->maxAttempts(3) ->retryStrategy('exponential') ->send(); echo $template['trigger_url']; // https://callmelater.io/t/clmt_abc123... ``` ```bash curl -X POST https://callmelater.io/api/v1/templates \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Deploy {{service}}", "description": "Triggers a deployment approval workflow", "type": "action", "mode": "approval", "timezone": "America/New_York", "gate_config": { "message": "Deploy {{service}} v{{version}} to {{env}}?", "recipients": ["ops@example.com"], "timeout": "4h", "on_timeout": "cancel" }, "request_config": { "url": "https://deploy.example.com/{{service}}", "method": "POST", "body": { "version": "{{version}}", "environment": "{{env}}" } }, "placeholders": [ { "name": "service", "required": true, "description": "Service name to deploy" }, { "name": "version", "required": true, "description": "Semantic version number" }, { "name": "env", "required": false, "default": "staging", "description": "Target environment" } ], "max_attempts": 3, "retry_strategy": "exponential" }' ``` --- ## Placeholders Placeholders let you inject dynamic values into templates at trigger time. They use the `{{placeholder_name}}` syntax. ### Defining placeholders Each placeholder has these properties: | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Variable name (alphanumeric and underscore) | | `required` | boolean | No | Whether the value must be provided (default: `false`) | | `description` | string | No | Human-readable description | | `default` | any | No | Default value when not provided at trigger time | ### Where placeholders work Placeholders are resolved in all of these fields: - **Template name** -- `Deploy {{service}}` - **Request URL** -- `https://api.example.com/{{service}}/deploy` - **Request body** -- `{ "version": "{{version}}" }` - **Request headers** -- `{ "Authorization": "Bearer {{api_token}}" }` - **Gate message** -- `Approve deployment of {{service}}?` - **Gate recipients** -- `["email:{{approver_email}}"]` - **Dedup keys** -- `["deploy:{{service}}:{{env}}"]` If a required placeholder is missing from the trigger request, the API returns a `422` validation error. --- ## Triggering Trigger a template by sending a POST request to its public URL. No API key is needed -- the trigger token embedded in the URL serves as authentication. ### Basic trigger ```ts await client.trigger('clmt_abc123...', { service: 'api-gateway', version: '2.4.1', env: 'production', }); ``` ```php CallMeLater::trigger('clmt_abc123...', [ 'service' => 'api-gateway', 'version' => '2.4.1', 'env' => 'production', ]); ``` ```bash curl -X POST https://callmelater.io/t/clmt_abc123... \ -H "Content-Type: application/json" \ -d '{ "service": "api-gateway", "version": "2.4.1", "env": "production" }' ``` ### From a GitHub Actions workflow Since no API key is needed, templates are perfect for CI/CD. Add the trigger URL as a repository secret and call it from your workflow: ```yaml # .github/workflows/deploy.yml name: Deploy on: push: tags: ['v*'] jobs: deploy: runs-on: ubuntu-latest steps: - name: Request deployment approval run: | curl -X POST ${{ secrets.CALLMELATER_DEPLOY_URL }} \ -H "Content-Type: application/json" \ -d '{ "service": "api-gateway", "version": "${{ github.ref_name }}", "env": "production" }' ``` --- ## Chain Templates Templates can create multi-step chains instead of single actions. Set `type: "chain"` and define `chain_steps` instead of `request_config`. ```ts const chainTemplate = await client.template('Onboard {{user_email}}') .type('chain') .chainSteps([ { name: 'Create Account', type: 'webhook', url: 'https://api.example.com/users', method: 'POST', body: { email: '{{user_email}}' }, }, { name: 'Wait for provisioning', type: 'wait', wait: '5m', }, { name: 'Manager Approval', type: 'approval', gate: { message: 'Approve new account for {{user_email}}?', recipients: ['{{manager_email}}'], }, }, { name: 'Send Welcome', type: 'webhook', url: 'https://api.example.com/welcome', method: 'POST', body: { email: '{{user_email}}', user_id: '{{steps.0.response.id}}' }, condition: "{{steps.2.status}} == confirmed", }, ]) .chainErrorHandling('fail_chain') .placeholder('user_email', true, 'New user email') .placeholder('manager_email', true, 'Approving manager email') .send(); ``` ```php $chainTemplate = CallMeLater::template('Onboard {{user_email}}') ->type('chain') ->chainSteps([ [ 'name' => 'Create Account', 'type' => 'webhook', 'url' => 'https://api.example.com/users', 'method' => 'POST', 'body' => ['email' => '{{user_email}}'], ], [ 'name' => 'Wait for provisioning', 'type' => 'wait', 'wait' => '5m', ], [ 'name' => 'Manager Approval', 'type' => 'approval', 'gate' => [ 'message' => 'Approve new account for {{user_email}}?', 'recipients' => ['{{manager_email}}'], ], ], [ 'name' => 'Send Welcome', 'type' => 'webhook', 'url' => 'https://api.example.com/welcome', 'method' => 'POST', 'body' => ['email' => '{{user_email}}', 'user_id' => '{{steps.0.response.id}}'], 'condition' => '{{steps.2.status}} == confirmed', ], ]) ->chainErrorHandling('fail_chain') ->placeholder('user_email', required: true, description: 'New user email') ->placeholder('manager_email', required: true, description: 'Approving manager email') ->send(); ``` ```bash curl -X POST https://callmelater.io/api/v1/templates \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Onboard {{user_email}}", "type": "chain", "chain_steps": [ { "name": "Create Account", "type": "http_call", "url": "https://api.example.com/users", "method": "POST", "body": { "email": "{{user_email}}" } }, { "name": "Wait for provisioning", "type": "delay", "delay": "5m" }, { "name": "Manager Approval", "type": "gated", "gate": { "message": "Approve new account for {{user_email}}?", "recipients": ["{{manager_email}}"] } }, { "name": "Send Welcome", "type": "http_call", "url": "https://api.example.com/welcome", "method": "POST", "body": { "email": "{{user_email}}", "user_id": "{{steps.0.response.id}}" }, "condition": "{{steps.2.status}} == confirmed" } ], "chain_error_handling": "fail_chain", "placeholders": [ { "name": "user_email", "required": true, "description": "New user email" }, { "name": "manager_email", "required": true, "description": "Approving manager email" } ] }' ``` Triggering a chain template returns a chain object instead of a single action. Placeholders and `{{steps.N.response.*}}` interpolation work the same way. --- ## Management ```ts // Toggle active/inactive await client.toggleTemplate('tpl_abc123'); // Regenerate trigger token (old URL stops working immediately) const updated = await client.regenerateTemplateToken('tpl_abc123'); console.log(updated.trigger_url); // new URL // Update template configuration await client.template('Deploy {{service}} v2') .description('Updated deployment template') .maxAttempts(5) .update('tpl_abc123'); // Delete template await client.deleteTemplate('tpl_abc123'); // Check quota const limits = await client.templateLimits(); console.log(`${limits.remaining} of ${limits.max} templates remaining`); ``` ```php // Toggle active/inactive CallMeLater::toggleTemplate('tpl_abc123'); // Regenerate trigger token (old URL stops working immediately) $updated = CallMeLater::regenerateTemplateToken('tpl_abc123'); // Update template configuration CallMeLater::template('Deploy {{service}} v2') ->description('Updated deployment template') ->maxAttempts(5) ->update('tpl_abc123'); // Delete template CallMeLater::deleteTemplate('tpl_abc123'); // Check quota $limits = CallMeLater::templateLimits(); ``` ```bash # Toggle active/inactive curl -X POST https://callmelater.io/api/v1/templates/tpl_abc123/toggle-active \ -H "Authorization: Bearer sk_live_..." # Regenerate trigger token curl -X POST https://callmelater.io/api/v1/templates/tpl_abc123/regenerate-token \ -H "Authorization: Bearer sk_live_..." # Update template curl -X PUT https://callmelater.io/api/v1/templates/tpl_abc123 \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "description": "Updated deployment template", "max_attempts": 5 }' # Delete template curl -X DELETE https://callmelater.io/api/v1/templates/tpl_abc123 \ -H "Authorization: Bearer sk_live_..." # Check quota curl https://callmelater.io/api/v1/templates/limits \ -H "Authorization: Bearer sk_live_..." ``` :::warning Regenerating a trigger token invalidates the previous URL immediately. Any CI/CD pipelines or external systems using the old URL will start receiving 404 errors. Update all references before rotating tokens. ::: --- ## Rate Limits Template triggers are rate-limited to prevent abuse: | Scope | Limit | |-------|-------| | Per trigger token | 60 requests/minute | | Per IP address | 120 requests/minute | Exceeding these limits returns a `429 Too Many Requests` response. The `Retry-After` header indicates how many seconds to wait. --- ## Account & Settings Endpoints for managing your account quota, contacts, and domain verification. ## Quota ``` GET /quota ``` Returns your current billing period usage and plan limits. ### Response ```json { "period": { "start": "2026-02-01T00:00:00Z", "end": "2026-02-28T23:59:59Z" }, "actions": { "used": 47, "limit": 5000 }, "sms": { "used": 12, "limit": 100 }, "plan": { "name": "Pro", "features": [ "webhook_signatures", "sms_reminders", "team_features" ] } } ``` Usage resets on the 1st of each month at 00:00 UTC. Actions in `cancelled` status still count toward your monthly usage. Specific limits depend on your plan. --- ## Contacts Manage reusable recipient entries that can be referenced by ID in approval recipients instead of raw email addresses or phone numbers. ### List Contacts ``` GET /contacts ``` ```json { "data": [ { "id": "contact-uuid-1", "name": "John Smith", "email": "john@example.com", "phone": "+15551234567", "created_at": "2026-01-01T10:00:00Z", "updated_at": "2026-01-01T10:00:00Z" } ] } ``` ### Create Contact ``` POST /contacts ``` | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Display name (max 255 chars) | | `email` | string | No* | Email address | | `phone` | string | No* | Phone number in E.164 format (e.g., `+15551234567`) | *At least one of `email` or `phone` is required. ### Get Contact ``` GET /contacts/{id} ``` ### Update Contact ``` PUT /contacts/{id} ``` All fields are optional. Only provided fields are updated. ### Delete Contact ``` DELETE /contacts/{id} ``` ### Usage in Approvals Reference contacts by ID in the `gate.recipients` array using the format `contact:{id}:email` or `contact:{id}:phone`: ```json { "mode": "approval", "gate": { "message": "Please approve deployment", "recipients": ["contact:contact-uuid-1:email", "contact:contact-uuid-2:phone"], "channels": ["email", "sms"] } } ``` The system looks up each contact's information and sends through the specified channel. --- ## Domain Verification Domain verification is required when your account exceeds usage thresholds to a single domain: - More than **10 actions per day** to the same domain - More than **100 actions per month** to the same domain Until verified, new actions targeting that domain will be rejected with a `422` error. ### List Domains ``` GET /domains ``` Returns all domains associated with your account and their verification status. ```json { "data": [ { "domain": "api.example.com", "verified": true, "verified_at": "2026-01-01T10:00:00Z", "method": "dns" }, { "domain": "hooks.myapp.io", "verified": false, "verified_at": null, "method": null } ] } ``` ### Start Verification ``` POST /domains/{domain}/verify ``` Initiates the verification process and returns instructions for both supported methods. ```json { "domain": "api.example.com", "verification_token": "cml_abc123def456", "verification_methods": { "dns": { "type": "TXT", "value": "callmelater-verification=cml_abc123def456" }, "file": { "url": "https://api.example.com/.well-known/callmelater-verify.txt", "content": "callmelater-verification=cml_abc123def456" } } } ``` **Method 1 -- DNS TXT Record:** Add a TXT record with the provided value to your domain's DNS. Allow up to 24 hours for propagation, then check verification status. **Method 2 -- File Verification:** Create a publicly accessible file at `https://yourdomain.com/.well-known/callmelater-verify.txt` containing the verification string. ### Get Domain ``` GET /domains/{domain} ``` Returns verification instructions and current status for a specific domain. ### Remove Domain ``` DELETE /domains/{domain} ``` Removes the domain from your account. You will need to re-verify if you exceed thresholds again. ### Notes - Subdomains are verified independently (`api.example.com` and `www.example.com` are separate) - Once verified, domain verification does not expire - Wildcard verification is not supported --- ## Actions Actions are the core primitive in CallMeLater. An action is either a scheduled HTTP request (**webhook** mode) or a human approval request (**approval** mode) that executes at a specified time. ## Create Action ``` POST /actions ``` ### Common Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `mode` | string | No | `webhook` (default) or `approval` | | `name` | string | No | Display name (auto-generated if omitted) | | `description` | string | No | Optional description (max 1000 chars) | | `timezone` | string | No | IANA timezone for scheduling (e.g., `America/New_York`). Defaults to `UTC`. | | `idempotency_key` | string | No | Unique key to prevent duplicate creation (max 255 chars) | | `callback_url` | string | No | URL to receive lifecycle events (executed, failed, expired) | ### Schedule Fields At least one scheduling field is required. | Field | Type | Example | Description | |-------|------|---------|-------------| | `schedule.preset` | string | `"tomorrow"` | Named time preset | | `schedule.wait` | string | `"2h"` | Relative wait duration from now | | `scheduled_for` | string | `"2026-04-01T09:00:00Z"` | Exact UTC timestamp (top-level field) | **Presets:** `tomorrow`, `next_week`, `next_monday` through `next_sunday`, `1h`, `2h`, `4h`, `1d`, `3d`, `1w` **Wait format:** Number followed by a unit -- `m` (minutes), `h` (hours), `d` (days), `w` (weeks). Examples: `30m`, `2h`, `14d`, `1w`. ### Webhook Mode Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `request` | object | Yes | HTTP request configuration | | `request.url` | string | Yes | Destination URL (HTTPS required in production) | | `request.method` | string | No | HTTP method (default: `POST`) | | `request.headers` | object | No | Custom headers as key-value pairs | | `request.body` | object | No | JSON request body | | `max_attempts` | integer | No | Maximum delivery attempts, 1-10 (default: 5) | | `retry_strategy` | string | No | `exponential` (default) or `linear` | | `webhook_secret` | string | No | Secret used to sign the outgoing request | ### Approval Mode Fields | Field | Type | Required | Description | |-------|------|----------|-------------| | `gate` | object | Yes | Approval gate configuration | | `gate.message` | string | Yes | Message displayed to recipients (max 5000 chars) | | `gate.recipients` | array | Yes | Email addresses, phone numbers (E.164), or contact IDs | | `gate.channels` | array | No | Delivery channels: `["email"]` (default) or `["email", "sms"]` | | `gate.confirmation_mode` | string | No | `first_response` (default) or `all_required` | | `gate.max_snoozes` | integer | No | Maximum snooze count, 0-10 (default: 5) | | `gate.timeout` | string | No | Response timeout, e.g. `4h`, `7d`, `1w` (default: `7d`) | | `gate.escalation_contacts` | array | No | Email addresses for escalation | | `gate.escalation_after_hours` | number | No | Hours before escalating (min: 0.5) | | `gate.attachments` | array | No | File attachments for the approval message | | `request` | object | No | Optional HTTP request to execute after approval is granted | ### Dedup Keys Fields Group related actions and control their behavior with dedup keys. | Field | Type | Required | Description | |-------|------|----------|-------------| | `dedup_keys` | array | No | Grouping keys (max 10). Alphanumeric plus `_`, `:`, `.`, `-`. | | `coordination` | object | No | Dedup behavior configuration | | `coordination.on_create` | string | No | `skip_if_exists` or `cancel_and_replace` | | `coordination.on_execute` | object | No | Execution-time conditions | | `coordination.on_execute.condition` | string | No | `skip_if_previous_pending`, `execute_if_previous_failed`, `execute_if_previous_succeeded`, `wait_for_previous` | | `coordination.on_execute.on_condition_not_met` | string | No | `cancel` (default), `reschedule`, `fail` | | `coordination.on_execute.reschedule_delay` | integer | No | Seconds before retry, 60-86400 (default: 300) | | `coordination.on_execute.max_reschedules` | integer | No | Max reschedule attempts, 1-100 (default: 10) | **on_create behaviors:** - `skip_if_exists` -- Return the existing non-terminal action instead of creating a new one. The response uses status 200 with `meta.skipped: true`. - `cancel_and_replace` -- Cancel all existing non-terminal actions sharing the same dedup keys, then create the new action. **on_execute conditions:** - `skip_if_previous_pending` -- Cancel this action if another non-terminal action with the same key exists. - `execute_if_previous_failed` -- Only execute if the most recent action with the same key failed. - `execute_if_previous_succeeded` -- Only execute if the most recent action with the same key succeeded. - `wait_for_previous` -- Reschedule until the previous action with the same key reaches a terminal state. ### Example: Webhook Mode ```bash curl -X POST https://api.callmelater.io/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "webhook", "name": "Trial expiration webhook", "idempotency_key": "trial-end-user-42", "schedule": { "wait": "14d" }, "request": { "method": "POST", "url": "https://api.example.com/webhooks/trial-expired", "headers": { "X-Custom-Header": "value" }, "body": { "event": "trial_expired", "user_id": 42 } }, "max_attempts": 5, "retry_strategy": "exponential" }' ``` ### Example: Approval Mode ```bash curl -X POST https://api.callmelater.io/v1/actions \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "mode": "approval", "name": "Production deployment approval", "idempotency_key": "deploy-approval-abc123", "schedule": { "wait": "30m" }, "gate": { "message": "Please approve the production deployment for release v2.1.0", "recipients": ["tech-lead@example.com", "+15551234567"], "channels": ["email", "sms"], "timeout": "4h", "max_snoozes": 3 }, "callback_url": "https://api.example.com/webhooks/approval-response" }' ``` ### Responses **201 Created** ```json { "data": { "id": "01234567-89ab-cdef-0123-456789abcdef", "name": "Trial expiration webhook", "description": null, "mode": "webhook", "status": "scheduled", "timezone": "UTC", "scheduled_for": "2026-02-04T10:30:00Z", "idempotency_key": "trial-end-user-42", "dedup_keys": [], "attempt_count": 0, "max_attempts": 5, "retry_strategy": "exponential", "created_at": "2026-01-21T10:30:00Z", "updated_at": "2026-01-21T10:30:00Z" } } ``` **200 Skipped** (when `coordination.on_create` is `skip_if_exists` and a matching action exists) ```json { "data": { "id": "existing-action-id", "name": "Existing action", "status": "scheduled" }, "meta": { "skipped": true, "reason": "existing_action_found" } } ``` **422 Validation Error** ```json { "message": "The given data was invalid.", "errors": { "request.url": ["The request.url field is required."] } } ``` --- ## List Actions ``` GET /actions ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `status` | string | -- | Filter by status: `scheduled`, `resolved`, `executing`, `awaiting_response`, `executed`, `failed`, `cancelled`, `expired` | | `mode` | string | -- | Filter by mode: `webhook` or `approval` | | `search` | string | -- | Search in name and description | | `dedup_key` | string | -- | Filter by dedup key | | `per_page` | integer | 15 | Results per page (max 100) | | `page` | integer | 1 | Page number | ### Response ```json { "data": [ { "id": "01234567-89ab-cdef-0123-456789abcdef", "name": "Trial expiration webhook", "mode": "webhook", "status": "scheduled", "scheduled_for": "2026-02-04T10:30:00Z", "idempotency_key": "trial-end-user-42", "dedup_keys": [], "created_at": "2026-01-21T10:30:00Z", "updated_at": "2026-01-21T10:30:00Z" } ], "meta": { "current_page": 1, "last_page": 5, "total": 72 } } ``` --- ## Get Action ``` GET /actions/{id} ``` Returns the full action detail including delivery history and approval events. ### Response Fields | Field | Type | Description | |-------|------|-------------| | `id` | string | Action UUID | | `name` | string | Display name | | `mode` | string | `webhook` or `approval` | | `status` | string | Current status | | `scheduled_for` | string | Scheduled execution time (ISO 8601) | | `executed_at` | string | Actual execution time (if completed) | | `request` | object | HTTP request config (webhook mode) | | `gate` | object | Approval gate config (approval mode) | | `delivery_attempts` | array | HTTP delivery attempt log (webhook mode) | | `reminder_events` | array | Approval event timeline (approval mode) | | `dedup_keys` | array | Dedup keys assigned to this action | | `created_at` | string | Creation timestamp | | `updated_at` | string | Last update timestamp | --- ## Cancel Action ``` DELETE /actions/{id} ``` Cancel a pending action before it executes. **Cancellable statuses:** `scheduled`, `resolved`, `awaiting_response` Actions that have already reached a terminal status (`executed`, `failed`, `expired`) cannot be cancelled. ### Response **200 OK** ```json { "data": { "id": "01234567-89ab-cdef-0123-456789abcdef", "name": "Trial expiration webhook", "status": "cancelled" } } ``` ### Cancel by Idempotency Key You can also cancel an action using its idempotency key instead of the UUID: ``` DELETE /actions ``` ```json { "idempotency_key": "trial-end-user-42" } ``` --- ## Retry Action ``` POST /actions/{id}/retry ``` Manually retry a failed action. This resets the attempt counter and creates a new execution cycle. ### Requirements - The action must be in `failed` status - The action must have an HTTP request configuration (webhook mode) ### Response **200 OK** ```json { "data": { "id": "01234567-89ab-cdef-0123-456789abcdef", "name": "Failed webhook", "status": "resolved", "attempt_count": 0, "manual_retry_count": 1 } } ``` **422 Unprocessable Entity** (if the action is not in `failed` status or has no HTTP config) --- ## List Coordination Keys ``` GET /coordination-keys ``` Returns a list of all dedup keys used across your account. Use this to discover keys, then filter actions by a specific key using the `dedup_key` parameter on the [List Actions](#list-actions) endpoint. ### Response ```json { "keys": [ "deploy:api-service", "deploy:web-app", "migration:db-upgrade", "user:42:trial" ] } ``` --- ## Authentication All API requests require authentication using a Bearer token. ## Getting an API Token 1. Log in to your [CallMeLater dashboard](https://app.callmelater.io) 2. Navigate to **Settings** -> **API Tokens** 3. Click **Create Token** 4. Give it a descriptive name and select the required scopes 5. Copy the token immediately -- it will not be shown again All tokens use the `sk_live_` prefix. ## Using Your Token Include the token in the `Authorization` header of every request: ```bash curl https://api.callmelater.io/v1/actions \ -H "Authorization: Bearer sk_live_your_token_here" \ -H "Content-Type: application/json" ``` ## Token Scopes | Scope | Permissions | |-------|-------------| | `read` | List and retrieve actions, chains, templates, team members, quota | | `write` | Create, cancel, and retry actions; manage chains, templates, and team members | When creating a token, select only the scopes your integration requires. ## Error Responses ### 401 Unauthorized Returned when the token is missing, invalid, expired, or revoked. ```json { "message": "Unauthenticated." } ``` ### 403 Forbidden Returned when the token lacks the required scope for the requested operation. ```json { "message": "This action is unauthorized." } ``` ## Best Practices - **Store tokens in environment variables.** Never hard-code tokens in source code or commit them to version control. Use `CALLMELATER_API_KEY` in your `.env` file and reference it at runtime. - **Rotate tokens regularly.** Create a new token, update your integrations, then revoke the old one. Set expiration dates for tokens used in temporary or CI/CD contexts. - **Use the minimum required scope.** If your integration only reads action statuses, issue a `read`-only token. Reserve `write` scope for services that create or cancel actions. --- ## Chains Chains are multi-step workflows that execute actions sequentially. Each step can be an HTTP call, a human approval gate, or a timed wait. Data flows between steps through variable interpolation. ## Create Chain ``` POST /chains ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Chain name (max 255 chars) | | `steps` | array | Yes | Step definitions (min 2, max 20) | | `input` | object | No | Input variables available for interpolation in all steps | | `error_handling` | string | No | `fail_chain` (default) or `skip_step` | ### Step Types Every step requires a `name`, a `type`, and optionally a `condition`. **`http_call` step** | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Step name | | `url` | string | Yes | Request URL | | `method` | string | No | HTTP method (default: `POST`) | | `headers` | object | No | Request headers | | `body` | object | No | Request body | | `max_attempts` | integer | No | Max delivery attempts (default: 5) | | `retry_strategy` | string | No | `exponential` (default) or `linear` | | `condition` | string | No | Expression that must evaluate to true for the step to run | **`gated` step** | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Step name | | `gate.message` | string | Yes | Approval request message | | `gate.recipients` | array | Yes | Email addresses, phone numbers, or contact IDs | | `gate.timeout` | string | No | Response timeout (e.g., `4h`, `7d`). Default: `7d`. | | `gate.on_timeout` | string | No | `cancel` (default), `expire`, or `approve` | | `gate.confirmation_mode` | string | No | `first_response` (default) or `all_required` | | `gate.max_snoozes` | integer | No | Max snooze count (default: 5) | | `condition` | string | No | Expression that must evaluate to true for the step to run | **`delay` step** | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Step name | | `delay` | string | Yes | How long to pause (e.g., `5m`, `1h`, `2d`) | | `condition` | string | No | Expression that must evaluate to true for the step to run | > **Note:** Responses return `webhook`, `approval`, `wait` as aliases for `http_call`, `gated`, `delay` respectively. ### Variable Interpolation Steps can reference input data and results from earlier steps: - `{{input.field}}` -- Access values from the chain's `input` object - `{{steps.N.response.field}}` -- Access the JSON response body of step N (zero-indexed) - `{{steps.N.status}}` -- Access the outcome status of step N ### Condition Operators | Operator | Description | |----------|-------------| | `==` | Equal | | `!=` | Not equal | | `>` | Greater than | | `<` | Less than | | `>=` | Greater than or equal | | `<=` | Less than or equal | | `contains` | String contains substring | | `not_contains` | String does not contain substring | | `starts_with` | String starts with prefix | | `ends_with` | String ends with suffix | Example condition: `{{steps.1.status}} == 'confirmed'` ### Example ```bash curl -X POST https://api.callmelater.io/v1/chains \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Expense Approval Workflow", "steps": [ { "name": "Submit expense", "type": "http_call", "url": "https://api.example.com/expenses", "method": "POST", "body": { "amount": "{{input.amount}}", "description": "{{input.description}}" } }, { "name": "Manager approval", "type": "gated", "gate": { "message": "Approve expense of ${{input.amount}} for {{input.description}}?", "recipients": ["manager@example.com"], "timeout": "4h", "on_timeout": "cancel" } }, { "name": "Process payment", "type": "http_call", "url": "https://api.example.com/payments", "method": "POST", "body": { "expense_id": "{{steps.0.response.id}}" }, "condition": "{{steps.1.status}} == '\''confirmed'\''" } ], "input": { "amount": 150.00, "description": "Team lunch" } }' ``` --- ## List Chains ``` GET /chains ``` ### Query Parameters | Parameter | Type | Default | Description | |-----------|------|---------|-------------| | `status` | string | -- | Filter by status: `pending`, `running`, `completed`, `failed`, `cancelled` | | `per_page` | integer | 15 | Results per page (max 100) | | `page` | integer | 1 | Page number | ### Response ```json { "data": [ { "id": "01chain789-...", "name": "Expense Approval Workflow", "status": "running", "current_step": 1, "created_at": "2026-01-15T10:00:00Z" } ], "meta": { "current_page": 1, "last_page": 1, "total": 1 } } ``` --- ## Get Chain ``` GET /chains/{id} ``` Returns the chain with its full step array, including each step's current status, response data (in `context`), `started_at`, and `completed_at` timestamps. ### Response ```json { "data": { "id": "01chain789-...", "name": "Expense Approval Workflow", "status": "running", "current_step": 1, "steps": [ { "name": "Submit expense", "type": "http_call", "status": "completed", "started_at": "2026-01-15T10:00:05Z", "completed_at": "2026-01-15T10:00:06Z" }, { "name": "Manager approval", "type": "approval", "status": "running", "started_at": "2026-01-15T10:00:07Z", "completed_at": null }, { "name": "Process payment", "type": "http_call", "status": "pending", "started_at": null, "completed_at": null } ], "context": { "steps": { "0": { "status": "completed", "response": { "id": "exp_123" } } } }, "error_handling": "fail_chain", "created_at": "2026-01-15T10:00:00Z" } } ``` --- ## Cancel Chain ``` DELETE /chains/{id} ``` Only chains in `pending` or `running` status can be cancelled. Cancelling a chain also cancels any currently pending steps. ### Response ```json { "data": { "id": "01chain789-...", "status": "cancelled" } } ``` --- ## Statuses **Chain statuses:** `pending`, `running`, `completed`, `failed`, `cancelled` **Step statuses:** `pending`, `running`, `completed`, `failed`, `skipped`, `cancelled` --- ## Templates(Api) Templates let you define reusable action or chain configurations with a unique trigger URL. Each template gets a public endpoint that can be called without API key authentication, making templates ideal for CI/CD pipelines, external integrations, and no-code tools. ## Create Template ``` POST /templates ``` ### Request Body | Field | Type | Required | Description | |-------|------|----------|-------------| | `name` | string | Yes | Template name (max 255 chars). Supports `{{placeholder}}` syntax. | | `description` | string | No | Optional description (max 1000 chars) | | `type` | string | No | `action` (default) or `chain` | | `mode` | string | Yes* | `webhook` or `approval`. Required when `type` is `action`. | | `timezone` | string | No | Default timezone (e.g., `America/New_York`) | **For action templates (`type: "action"`):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `request_config` | object | Yes* | HTTP request configuration. Required for `webhook` mode. | | `request_config.url` | string | Yes | Destination URL. Supports `{{placeholder}}` syntax. | | `request_config.method` | string | No | HTTP method (default: `POST`) | | `request_config.headers` | object | No | Custom headers. Values support `{{placeholder}}`. | | `request_config.body` | object | No | JSON body. Values support `{{placeholder}}`. | | `gate_config` | object | Yes** | Gate configuration. Required for `approval` mode. | | `gate_config.message` | string | Yes | Approval message. Supports `{{placeholder}}`. | | `gate_config.recipients` | array | Yes | Recipient list. Values support `{{placeholder}}`. | | `gate_config.timeout` | string | No | Response timeout (default: `7d`) | | `gate_config.confirmation_mode` | string | No | `first_response` (default) or `all_required` | | `gate_config.max_snoozes` | integer | No | Max snooze count (default: 5) | | `max_attempts` | integer | No | Max delivery attempts, 1-10 (default: 5) | | `retry_strategy` | string | No | `exponential` (default) or `linear` | **For chain templates (`type: "chain"`):** | Field | Type | Required | Description | |-------|------|----------|-------------| | `chain_steps` | array | Yes | Array of step definitions (min 2, max 20). See [Chains](/api/chains). | | `chain_error_handling` | string | No | `fail_chain` (default) or `skip_step` | **Placeholders:** | Field | Type | Required | Description | |-------|------|----------|-------------| | `placeholders` | array | No | Placeholder definitions | | `placeholders[].name` | string | Yes | Variable name (alphanumeric and underscore) | | `placeholders[].required` | boolean | No | Whether the value must be provided (default: `false`) | | `placeholders[].description` | string | No | Human-readable description | | `placeholders[].default` | string | No | Default value when not provided at trigger time | **Dedup keys:** Templates support `dedup_keys` with `{{placeholder}}` interpolation, allowing dynamic grouping at trigger time. ### Example ```bash curl -X POST https://api.callmelater.io/v1/templates \ -H "Authorization: Bearer sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "name": "Deploy {{service}}", "mode": "approval", "gate_config": { "message": "Deploy {{service}} v{{version}} to {{env}}?", "recipients": ["ops@example.com"], "timeout": "4h" }, "request_config": { "url": "https://deploy.example.com/{{service}}", "method": "POST", "body": { "version": "{{version}}", "environment": "{{env}}" } }, "placeholders": [ { "name": "service", "required": true, "description": "Service name" }, { "name": "version", "required": true, "description": "Version number" }, { "name": "env", "required": false, "default": "staging", "description": "Target environment" } ], "dedup_keys": ["deploy:{{service}}:{{env}}"] }' ``` ### Response (201 Created) ```json { "data": { "id": "01abc123-...", "name": "Deploy {{service}}", "mode": "approval", "trigger_url": "https://api.callmelater.io/t/clmt_abc123...", "trigger_token": "clmt_abc123...", "placeholders": [ { "name": "service", "required": true, "description": "Service name" }, { "name": "version", "required": true, "description": "Version number" }, { "name": "env", "required": false, "default": "staging", "description": "Target environment" } ], "is_active": true, "trigger_count": 0, "created_at": "2026-01-15T10:00:00Z" } } ``` --- ## Trigger Template ``` POST /t/{trigger_token} ``` This is a **public endpoint** -- no API key authentication is required. The trigger token in the URL authenticates the request. ### Request Body Send placeholder values as a flat JSON object. Optionally include a `schedule` override. ```json { "service": "api-gateway", "version": "2.4.1", "env": "production", "schedule": { "wait": "5m" } } ``` If no `schedule` is provided, the action is created with a minimal delay (approximately 1 second). ### Response (201 Created) ```json { "message": "Action created from template.", "data": { "id": "01xyz789-...", "name": "Deploy api-gateway", "mode": "approval", "status": "scheduled", "template_id": "01abc123-...", "created_at": "2026-01-15T14:30:00Z" } } ``` ### Rate Limits | Scope | Limit | |-------|-------| | Per token | 60 requests/minute | | Per IP | 120 requests/minute | --- ## List Templates ``` GET /templates ``` Returns a paginated list of your templates with trigger counts and last-triggered timestamps. --- ## Get Template ``` GET /templates/{id} ``` Returns the full template configuration including placeholders, request/gate config, and trigger statistics. --- ## Update Template ``` PUT /templates/{id} ``` Accepts the same fields as Create. Only provided fields are updated. The trigger URL is not affected. --- ## Delete Template ``` DELETE /templates/{id} ``` Permanently deletes the template. The trigger URL immediately stops working. Returns `204 No Content`. --- ## Regenerate Token ``` POST /templates/{id}/regenerate-token ``` Generates a new trigger token and URL. The previous URL immediately stops working. ### Response ```json { "message": "Trigger token regenerated successfully.", "data": { "trigger_token": "clmt_newtoken...", "trigger_url": "https://api.callmelater.io/t/clmt_newtoken..." } } ``` --- ## Toggle Active ``` POST /templates/{id}/toggle-active ``` Enables or disables the template. Inactive templates return `404` when their trigger URL is called. --- ## Get Limits ``` GET /templates/limits ``` Returns the maximum number of templates allowed on your plan and your current count. ### Response ```json { "current": 2, "max": 25, "remaining": 23, "plan": "pro" } ``` Limits depend on your plan. --- ## Limits, Plans & Changelog ## API Rate Limits | Endpoint | Authenticated | Unauthenticated | |----------|---------------|-----------------| | All API endpoints | 100 req/min | 20 req/min | | Create action | 100 req/hour | N/A | | Reminder response | N/A | 10 req/min per token | | Template trigger | 60 req/min per token | 120 req/min per IP | ### Rate limit headers Every response includes: | Header | Description | |--------|-------------| | `X-RateLimit-Limit` | Max requests in the window | | `X-RateLimit-Remaining` | Requests remaining | | `X-RateLimit-Reset` | Unix timestamp when limit resets | When exceeded, you'll receive `429 Too Many Requests` with a `Retry-After` header. ## Plan Limits Plan limits (actions per month, retry attempts, templates, retention) vary by plan. See your dashboard or [pricing page](https://callmelater.io/pricing) for current limits. ## Backwards Compatibility The API accepts both old and new field names. **New names are recommended.** Old names will be removed in API v2. | Current Name | Old Name (deprecated) | |---|---| | `schedule` | `intent` | | `schedule.wait` | `intent.delay` | | `scheduled_for` | `execute_at` / `execute_at_utc` | | `mode: "webhook"` | `mode: "immediate"` / `type: "immediate"` | | `mode: "approval"` | `mode: "gated"` / `type: "gated"` | | Status: `scheduled` | `pending_resolution` | | `dedup_keys` | `coordination_keys` | | `coordination` | _(no alias — only `coordination` is accepted)_ | | `GET /coordination-keys` | _(no alias — only `/coordination-keys` exists)_ | ## Changelog ### 2025-01-04 **Initial public release** - HTTP action scheduling with exponential and linear retry strategies - Approval actions with email and SMS delivery - Idempotency key support and cancel-by-key - Webhook signatures (HMAC-SHA256) - SSRF protection for outgoing requests - Chains (multi-step workflows) - Templates with placeholder support - Domain verification - Team member management ### Versioning Policy - Breaking changes increment the major version (v1 → v2) - New features are added without version change - Deprecated features are maintained for at least 6 months | Version | Status | |---------|--------| | v1 | Current | --- ## Security & Webhooks ## Receiving Webhooks When CallMeLater delivers an HTTP action, it sends a signed request to your endpoint. ### Request headers | Header | Description | |--------|-------------| | `Content-Type` | `application/json` | | `User-Agent` | `CallMeLater/1.0` | | `X-CallMeLater-Action-Id` | Action UUID | | `X-CallMeLater-Timestamp` | Unix timestamp | | `X-CallMeLater-Signature` | HMAC signature (if secret configured) | The body is the exact JSON you provided when creating the action, plus any custom headers you specified. ### Response expectations | Your Response | What Happens | |---------------|-------------| | `2xx` | Success — action marked as `executed` | | `4xx` (except 429) | Permanent failure — no retry | | `429` | Rate limited — retry with backoff | | `5xx` | Temporary failure — retry | | Timeout (30s) | Retry | :::tip Return 200 immediately and process asynchronously. Requests timeout after 30 seconds. ::: ## Webhook Signatures Actions are signed with your webhook secret (Settings → Webhook Secret) or the per-action `webhook_secret`. ``` X-CallMeLater-Signature: sha256=5d41402abc4b2a76b9719d911017c592 ``` ### Verifying signatures **PHP** ```php $payload = file_get_contents('php://input'); $signature = $_SERVER['HTTP_X_CALLMELATER_SIGNATURE']; $expected = 'sha256=' . hash_hmac('sha256', $payload, $webhookSecret); if (!hash_equals($expected, $signature)) { http_response_code(401); exit('Invalid signature'); } ``` **Node.js** ```javascript const crypto = require('crypto'); function verifySignature(payload, signature, secret) { const expected = 'sha256=' + crypto .createHmac('sha256', secret) .update(payload) .digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(expected) ); } ``` **Python** ```python def verify_signature(payload: bytes, signature: str, secret: str) -> bool: expected = 'sha256=' + hmac.new( secret.encode(), payload, hashlib.sha256 ).hexdigest() return hmac.compare_digest(expected, signature) ``` :::info The Node.js and Laravel SDKs handle signature verification automatically. See [Node.js SDK](/sdks/nodejs#webhooks) or [Laravel SDK](/sdks/laravel#webhooks). ::: ### Timestamp validation Reject old requests to prevent replay attacks: ```javascript const age = Date.now() / 1000 - parseInt(req.headers['x-callmelater-timestamp']); if (age > 300) return res.status(401).send('Request too old'); // 5 minutes ``` ## SSRF Protection Requests are blocked to: - Private IPs (`10.x.x.x`, `192.168.x.x`, `172.16-31.x.x`) - Loopback (`127.x.x.x`, `::1`, `localhost`) - Link-local (`169.254.x.x`) - Cloud metadata (`169.254.169.254`) - Internal hostnames (`*.local`, `*.internal`) DNS rebinding is also prevented — hostnames are resolved and IPs validated before requests. ## IP Allowlisting All outbound HTTP calls originate from: ``` 203.0.113.50 ``` You can also fetch this from `https://callmelater.io/api/public/server-info`. ### Firewall examples **AWS Security Group:** ``` Type: Custom TCP | Port: 443 | Source: 203.0.113.50/32 ``` **nginx:** ```nginx location /webhook { allow 203.0.113.50; deny all; } ``` ## Data Security - **Encryption at rest:** AES-256 - **Encryption in transit:** TLS 1.2+ - **HTTPS only:** HTTP requests are rejected Avoid including passwords, API keys, or PII in action payloads. Use references/IDs instead. ## Reporting Issues Email security@callmelater.io. We respond within 24 hours.