1. Go to this page and download the library: Download recado/recado-php library. Choose the download type require.
2. Extract the ZIP file and open the index.php.
3. Add this code to the index.php.
<?php
require_once('vendor/autoload.php');
/* Start to develop here. Best regards https://php-download.com/ */
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
// A Mailable through the transport (MAIL_MAILER=recado)
Mail::to('[email protected]')->send(new WelcomeMail($user));
// A plain message
Mail::raw('Hello world', fn ($m) => $m->to('[email protected]')->subject('Hi'));
$result = $client->notifications()->batch([
['to' => '[email protected]', 'title' => 'Shipped', 'body' => 'On its way.'],
[
'to' => '[email protected]',
'title' => 'Shipped',
'body' => 'On its way.',
'channels' => ['in_app', 'push'],
],
], idempotencyKey: 'orders-2026-08-15'); // optional
echo $result->queued; // channel dispatches accepted
echo $result->failed; // channel dispatches that could not be queued
foreach ($result->messages as $item) {
// $item->index, $item->to, $item->anyQueued()
$push = $item->channel('push');
if ($push && ! $push->queued()) {
echo $push->errorCode; // e.g. upgrade_
$result = $client->push()->register('[email protected]', 'fcm-device-token', 'android');
echo $result->registered; // true
echo $result->devices; // number of push devices now on the contact
$removed = $client->push()->remove('[email protected]', 'fcm-device-token');
echo $removed->removed; // true, or false if the contact had no such token
// Track an event
$client->send()->track('order.placed', '[email protected]', ['total' => 4200]);
// ...and set contact fields on the contact the event upserts (4th argument,
// optional): `first_name`, `last_name`, `name` and `locale`. Use `name` when
// you only have the full name — the API splits it on the first whitespace, and
// explicit `first_name`/`last_name` always win. Every field is set on create,
// updated when provided, and never cleared when omitted.
$client->send()->track('signed-up', '[email protected]', [], [
'first_name' => 'Jane',
'last_name' => 'Doe',
'locale' => 'es-MX',
]);
$client->send()->track('signed-up', '[email protected]', [], ['name' => 'Ada Lovelace']);
// The same array also takes `lists` (ids of your lists, max 50) and `tags`
// (names, max 25, created on first use). The API attaches them BEFORE it
// records the occurrence, so an event-triggered automation already sees them
// (a `has_tag` condition can match a tag sent with this very call). Both are
// idempotent and neither changes the contact's subscription status; an unknown
// list id throws a `ValidationException` with the code `list_not_found`.
$client->send()->track('signed-up', '[email protected]', [], [
'name' => 'Ada Lovelace',
'lists' => [1],
'tags' => ['beta'],
]);
// The positional arguments always win: an `email`/`event`/`data` key in the
// contact array is ignored, never a way to redirect the call.
// Subscribe a contact (double opt-in aware)
$client->send()->subscribe([
'email' => '[email protected]',
'first_name' => 'Jane',
'lists' => [7],
'tags' => ['newsletter'],
]);
// Contacts
$page = $client->contacts()->list(['status' => 'subscribed', 'per_page' => 50]);
$contact = $client->contacts()->get('[email protected]');
$client->contacts()->update('[email protected]', ['first_name' => 'Janet']);
// Bulk attribute upsert: up to 500 contacts per request, own 30/min limiter.
// Update-only (unknown emails come back as `skipped_not_found`, never created)
// and consent-safe (status, subscribed_at/unsubscribed_at and list memberships
// are never written). Attributes MERGE, so absent keys survive.
$result = $client->contacts()->batchUpdate([
[
'email' => '[email protected]',
'attributes' => ['plan' => 'pro', 'last_active_at' => '2026-09-10'],
'tags_add' => ['power-user'],
'tags_remove' => ['trial'],
],
], idempotencyKey: 'attribute-refresh-2026-09-10');
// $result['results'][0]['status'] === 'updated' | 'skipped_not_found' | 'invalid_attributes'
$client->contacts()->tags('[email protected]', add: ['vip'], remove: ['trial']);
$client->contacts()->cancelAutomationRuns('[email protected]', automation: 12);
$client->contacts()->delete('[email protected]'); // GDPR erase
// Lists
$lists = $client->lists()->list();
$list = $client->lists()->create('Newsletter', 'Weekly digest');
$client->lists()->attachContact($list->id, '[email protected]');
$client->lists()->detachContact($list->id, '[email protected]');
$client->lists()->update($list->id, ['name' => 'Weekly digest']); // membership untouched
$client->lists()->delete($list->id); // contacts survive
// Tags (flat array) — full CRUD, including the preference-center fields
$tags = $client->tags()->list();
// create() is create-or-FIND: an existing name (matched case-insensitively)
// comes back unchanged, so it never repaints a tag someone already curated.
$tag = $client->tags()->create('vip', [
'color' => '#16a34a',
'is_public' => true, // shows as an opt-in checkbox on the
'public_label' => 'Insider news', // preference center, labelled by
'public_description' => 'Occasional early access.',
]);
$client->tags()->update($tag->id, ['public_label' => 'Insiders']);
$client->tags()->delete($tag->id); // detached from every contact; contacts survive
// Bulk contact imports — the only surface that creates contacts in bulk WITH
// their consent state. Asynchronous: poll until the run is finished.
$import = $client->imports()->create([
['email' => '[email protected]', 'first_name' => 'Ada', 'tags' => ['vip']],
['email' => '[email protected]', 'status' => 'unsubscribed'],
], ['lists' => [12], 'tags' => ['migration-2026']]);
$import = $client->imports()->get($import->id);
$import->isFinished(); // status is completed or failed
$import->invalidStatusRows; // a warning: the row imported, its status did not parse
// Templates
$templates = $client->templates()->list();
$template = $client->templates()->create([
'name' => 'Welcome',
'slug' => 'welcome',
'subject' => 'Welcome!',
'body_html' => '<p>Hi</p>',
]);
$client->templates()->putVariant('welcome', 'es', [
'subject' => '¡Bienvenido!',
'body_html' => '<p>Hola</p>',
]);
// Notification templates (the in-app/push sibling — addressed by slug, which is
// what notifications()->send() accepts as `template`)
$client->notificationTemplates()->create([
'name' => 'Order shipped',
'slug' => 'order-shipped',
'title' => 'On its way, {{ contact.first_name }}',
'body' => 'Your order left the warehouse.',
'action_url' => 'myapp://orders/42', // deep links are allowed, scripts never
]);
// A variant is a FULL replace: omitting action_url RESETS it for that locale.
$client->notificationTemplates()->putVariant('order-shipped', 'es-MX', [
'title' => 'En camino',
'body' => 'Tu pedido salió del almacén.',
]);
$client->notificationTemplates()->delete('order-shipped');
// Messages
$messages = $client->messages()->list(['status' => 'delivered']);
$message = $client->messages()->get('11111111-2222-...');
foreach ($message->events as $event) {
// $event->type, $event->payload, $event->occurredAt
}
// Resend: queues a BRAND-NEW message with the original's recipient and rendered
// content. Suppression, quota and warm-up all re-run, so the copy can still be
// refused (`message_not_resendable`, `recipient_suppressed`, `quota_exceeded`).
$copy = $client->messages()->resend($message->uuid);
// Campaigns (full lifecycle — see "Campaigns" below)
$campaigns = $client->campaigns()->list(['status' => 'sent', 'per_page' => 50]);
$campaign = $client->campaigns()->get(7);
// $campaign->stats is a populated CampaignStats on get() (and on
// list(['
$campaign = $client->campaigns()->create([
'name' => 'July product update',
'subject' => 'What shipped in July',
'editor' => 'markdown', // blocks (default) | html | markdown — immutable
'content' => ['source' => '# Hi {{ contact.first_name }}'],
'lists' => [3],
'segments' => [7],
]);
$client->campaigns()->update($campaign->id, ['subject' => 'What actually shipped']);
// Look before you leap: how many people, what would fail, what it looks like.
$client->campaigns()->recipientCount(lists: [3], segments: [7]); // int
$readiness = $client->campaigns()->readiness($campaign->id);
foreach ($readiness->failures() as $check) {
echo $check->key.': '.$check->code.PHP_EOL; // e.g. quota: quota_exceeded
}
$preview = $client->campaigns()->preview($campaign->id, contactEmail: '[email protected]');
$client->campaigns()->testSend($campaign->id, ['[email protected]']);
$campaign = $client->campaigns()->create([
'name' => 'July product update',
'editor' => 'markdown',
'subject' => 'What shipped in July', // the base every variant inherits
'content' => ['source' => '# Hi {{ contact.first_name }}'],
'lists' => [3],
'ab_test' => [
'enabled' => true,
'test_fraction' => 0.2, // 0.1..0.5 of the audience (default 0.2)
'winner_metric' => 'opens', // opens | clicks (default opens)
'test_duration_minutes' => 240, // 30..2880 (default 240)
],
'variants' => [
['subject' => 'What shipped in July'], // inherits the campaign body
['subject' => 'July: 11 new things', 'content' => ['source' => '# Eleven']],
],
]);
$campaign->abTest->variants[0]->label; // 'A' — labels are server-assigned
if ($campaign->abTest?->locked) {
// ab_test_state is testing/deciding/finished — a write touching the
// variants now is a 422 with code ab_test_locked.
}
$campaign = $client->campaigns()->create([
'name' => 'July product update',
'editor' => 'markdown',
'subject' => 'What shipped in July',
'content' => ['source' => '# Hi {{ contact.first_name }}'],
'lists' => [3],
'locale_variants' => [
['locale' => 'es', 'subject' => 'Lo que lanzamos en julio', 'content' => ['source' => '# Hola']],
['locale' => 'fr', 'subject' => 'Nouveautes de juillet'], // French subject, English body
],
'variants' => [
[
'subject' => 'What shipped in July',
'locale_variants' => [['locale' => 'es', 'subject' => 'Lo que lanzamos en julio']],
],
[
'subject' => 'July: 11 new things',
'locale_variants' => [['locale' => 'es', 'subject' => 'Julio: 11 novedades']],
],
],
]);
$campaign->localeVariants[0]->locale; // 'es'
$campaign->abTest->variants[1]->localeVariants[0]->subject; // 'Julio: 11 novedades'
$client->campaigns()->schedule($campaign->id, '2026-07-05T09:00:00+00:00');
$client->campaigns()->unschedule($campaign->id); // back to draft
$client->campaigns()->cancel($campaign->id); // scheduled/sending → cancelled
$copy = $client->campaigns()->duplicate($campaign->id, 'Week 38'); // fresh draft
$client->campaigns()->delete($campaign->id); // draft/cancelled/failed only
// Filters, sorting and embedded stats for a table view.
$page = $client->campaigns()->list([
'status' => ['sent', 'failed'],
'search' => 'July',
'sort' => '-scheduled_at',
'include' => 'stats',
]);
// Refresh the metrics of rows you already hold (one aggregate query).
$stats = $client->campaigns()->stats([31, 32]); // [31 => CampaignStats, ...]
// Click and A/B reporting on the detail endpoint.
$campaign = $client->campaigns()->get(31, ['include' => 'top_links,variants']);
$campaign->topLinks[0]->url;
$campaign->variants[0]->isWinner;
// Publication flags (drafts only, like every campaign field):
// - in_archive (default true): listed on the project's public archive page
// and RSS feed once the campaign has been sent.
// - premium (default false): goes to paid subscribers only. Turning it ON
//
// Size the audience per channel BEFORE creating anything. Every sendable
// channel is counted, not only the ones a broadcast selects.
$counts = $client->broadcasts()->recipientCount(lists: [3], segments: [7]);
$counts->for('push'); // 612
$counts->recipientsTotal; // the SUM: a contact reachable twice is two sends
$broadcast = $client->broadcasts()->create([
'name' => 'Launch day', // internal only
'title' => 'We launched',
'body' => 'The new dashboard is live.',
'action_url' => 'myapp://dashboard', // deep links allowed, scripts never
'channels' => ['in_app', 'push'], // `email` is rejected
'lists' => [3],
'segments' => [7],
]);
// Title, body and channels may stay empty while drafting; they are enforced at
// send time, exactly like a campaign's subject and content.
$client->broadcasts()->update($broadcast->id, ['channels' => ['push']]);
// A proof to ONE existing contact of the project (an unknown address could only
// produce a blocked send). It carries no broadcast id, so stats stay clean.
$client->broadcasts()->testSend($broadcast->id, '[email protected]');
// Sending needs the same explicit confirmation a campaign send does: without
// `confirm: true` this throws CampaignSendNotConfirmedException locally and no
// request is made.
$client->broadcasts()->send($broadcast->id, confirm: true);
$client->broadcasts()->schedule($broadcast->id, '2026-12-01T09:00:00Z');
$client->broadcasts()->unschedule($broadcast->id); // back to draft
$client->broadcasts()->cancel($broadcast->id); // ends `cancelled`, not draft
$client->broadcasts()->get($broadcast->id)->stats?->openRate; // null on a zero denominator
foreach ($client->waitlists()->cursor(['status' => 'open', 'cal public host
$waitlist->membersCount; // total signups ever recorded
$waitlist->stats?->last7Days; // null unless you asked for the full list.
foreach ($client->waitlists()->membersCursor(7) as $member) {
$member->position; // 1-based
$member->referralCode; // the code in their own share link (not a secret)
$member->referralsCount; // credited referrals, never decremented
}
$waitlist = $client->waitlists()->launch(7);
// It claims open → launched atomically, stops the public signup page, tags
// every member contact `early-adopter` through the normal tag path (so
// `tag_added` automations fire), and creates a DRAFT announcement campaign
// targeted at the waitlist's own list:
$waitlist->launchCampaignId;
// Pass false to skip that campaign:
$client->waitlists()->launch(7, createCampaign: false);
$domain = $client->sendingDomains()->add('mail.example.com');
foreach ($domain->records as $record) {
// Shaped for a DNS panel: `host` relative to the zone, `fqdn` alongside,
// an MX `priority` split into its own field, a concrete `ttl` suggestion.
$record->type; $record->host; $record->fqdn; $record->value; $record->priority;
}
// `dmarc` is a RECOMMENDATION, never a
foreach ($domain->missingRecords() as $record) {
// Still on your side to publish.
}
$client->sendingDomains()->list(); // every identity, alphabetically
$client->sendingDomains()->get(12);
$client->sendingDomains()->delete(12); // also deletes the provider identity
$domain = $client->customDomains()->add('news.acme.com');
$domain->record->type; // CNAME
$domain->record->value; // the platform subdomain to point at
$domain = $client->customDomains()->check($domain->id);
$domain->isVerified();
$domain->tlsPending; // DNS ok, certificate still on its way
$domain->tlsError; // rate_limited | rejected | api_error | certificate_failed | timeout | exhausted
$client->customDomains()->current(); // ?CustomDomain
$client->customDomains()->delete($domain->id);
$estimate = $client->verification()->estimate(listId: 12);
$estimate->contactsInScope; // 1840
$estimate->addresses; // 1204 — what you would actually pay for
$estimate->creditsRemaining; // null when the provider reports no balance
$estimate->sufficientCredits;
$run = $client->verification()->run($estimate);
while (! $run->isFinished()) {
sleep(5);
$run = $client->verification()->get($run->id);
}
$run->updated; // addresses the provider answered for
$run->failed; // lookups that could not be made — a provider outage never
// rewrites a stored verdict
use Recado\Sdk\Exception\VerificationEstimateMismatchException;
try {
$run = $client->verification()->run($estimate);
} catch (VerificationEstimateMismatchException $e) {
$fresh = $e->currentEstimate(); // same scope, current numbers
$run = $client->verification()->run($fresh);
}
use Recado\Sdk\Exception\RecadoException;
try {
$client->sendingDomains()->list();
} catch (RecadoException $e) {
if ($e->isNotAvailableInSandbox()) {
// This credential is a sandbox key. Use the production one.
}
}
foreach ($client->contacts()->cursor(['status' => 'subscribed']) as $contact) {
echo $contact->email.PHP_EOL;
}
// Available cursors (each yields the same DTOs as the matching list()):
$client->contacts()->cursor($query); // Contact
$client->messages()->cursor($query); // Message
$client->campaigns()->cursor($query); // Campaign
$client->segments()->cursor($query); // Segment
$client->events()->cursor($query); // EventOccurrence
$client->events()->forContactCursor($email); // EventOccurrence
$client->lists()->cursor($query); // ContactList
$client->lists()->contactsCursor($id, $query); // Contact
$client->templates()->cursor($query); // Template
use Recado\Sdk\Exception\ValidationException;
use Recado\Sdk\Exception\RateLimitException;
use Recado\Sdk\Exception\RecadoException;
try {
$client->send()->email(['to' => '[email protected]', 'template' => 'welcome']);
} catch (ValidationException $e) {
if ($e->getErrorCode() === 'recipient_suppressed') {
// address is on the suppression list — skip it
}
$fieldErrors = $e->errors(); // ['to' => ['The to field is
// 1. Send through the code under test (sandbox token configured).
$client->send()->email(['to' => '[email protected]', 'template' => 'welcome']);
// 2. The sandbox captures every send — read it back like an inbox.
$message = $client->messages()->list(['status' => 'queued'])->data[0];
// 3. Drive the pipeline: simulate provider/engagement events on that message.
$client->sandbox()->simulate($message->uuid, SandboxResource::EVENT_DELIVERED);
$client->sandbox()->simulate($message->uuid, SandboxResource::EVENT_OPEN);
$client->sandbox()->simulate($message->uuid, SandboxResource::EVENT_CLICK, linkIndex: 0);
// 4. Assert on the resulting state.
$refreshed = $client->messages()->get($message->uuid);
// $refreshed->events now contains delivered / opened / clicked
use Recado\Sdk\Resources\SandboxResource;
// Available events (plain strings work too):
SandboxResource::EVENT_DELIVERED; // 'delivered'
SandboxResource::EVENT_HARD_BOUNCE; // 'hard_bounce'
SandboxResource::EVENT_SOFT_BOUNCE; // 'soft_bounce'
SandboxResource::EVENT_COMPLAINT; // 'complaint'
SandboxResource::EVENT_OPEN; // 'open'
SandboxResource::EVENT_CLICK; // 'click' — pass linkIndex or url
SandboxResource::EVENT_READ; // 'read'
use Recado\Sdk\RecadoClient;
public function __construct(private RecadoClient $recado) {}
// ...
$this->recado->send()->email([...]);
use Recado\Sdk\Laravel\Mail\RecadoHeaders;
class WelcomeMail extends Mailable
{
public function build()
{
return $this
->subject('Welcome') // ignored when a template header is present
->withSymfonyMessage(function ($message) {
$headers = $message->getHeaders();
$headers->addTextHeader(RecadoHeaders::TEMPLATE, 'welcome');
$headers->addTextHeader(
RecadoHeaders::VARIABLES,
json_encode(['first_name' => 'Jane']),
);
});
}
}
use Recado\Sdk\Laravel\Mail\RecadoMessage;
public function toRecado($notifiable): RecadoMessage
{
return (new RecadoMessage)
->subject('Your order shipped')
->html('<p>It is on the way.</p>')
->text('It is on the way.');
}
return (new RecadoMessage)
->template('order-shipped')
->variables(['name' => $notifiable->name]);
use Illuminate\Notifications\Notification;
use Recado\Sdk\Laravel\Mail\RecadoMessage;
class OrderShipped extends Notification
{
public function via($notifiable): array
{
return ['recado'];
}
public function toRecado($notifiable): RecadoMessage
{
return (new RecadoMessage)
->subject('Your order shipped')
->html('<p>It is on the way.</p>');
// Or a stored template:
// return (new RecadoMessage)->template('order-shipped')->variables(['name' => $notifiable->name]);
}
}