Official PHP client for the Posta email platform.
It covers the whole Posta API: transactional and templated sending, batch sends, address verification, templates with versions and localizations, campaigns, subscribers and lists, suppressions and bounces, domains, SMTP servers and relay credentials, webhooks, web forms and the messages they collect, inbound email, workspace administration, and the platform admin surface.
Uses ext-curl and ext-json only — no Composer dependencies.
composer require goposta/posta-phpRequires: PHP 8.1+
use Posta\PostaClient;
$posta = new PostaClient('https://posta.example.com', 'psk_your_api_key');
$resp = $posta->emails->send([
'from' => 'Acme <hello@example.com>',
'to' => ['user@example.com'],
'subject' => 'Hello from Posta',
'html' => '<h1>Hello!</h1>',
]);
echo "sent: id={$resp['id']} status={$resp['status']}\n";Most machine-facing endpoints take an API key:
$posta = new PostaClient('https://posta.example.com', 'psk_...');Account-level endpoints (/users/me/*) and the platform admin surface accept
only a user session token — an API key is never a valid credential there:
$auth = (new PostaClient($baseUrl, ''))->auth->login('admin@example.com', $password);
$admin = PostaClient::withToken($baseUrl, $auth['token']);Workspace-scoped endpoints resolve the active workspace from the
X-Posta-Workspace-Id header. A workspace-bound API key already carries its
workspace; an account-wide key or a user session must name one:
$posta = new PostaClient($baseUrl, $apiKey, timeout: 30, workspaceId: 42);A key reaches only what its scopes allow. Scope::SEND covers the public send
API; READ and WRITE cover reading and mutating workspace resources;
WEBHOOKS covers webhook management; ADMIN covers tenant administration
(keys, members, settings); ALL grants everything.
A 403 from an endpoint you expect to work usually means a missing scope —
$err->isForbidden() distinguishes it.
$posta = new PostaClient(
'https://posta.example.com',
'psk_...',
timeout: 15, // seconds, default 30
workspaceId: 42,
headers: ['X-Request-Source' => 'batch-job'],
userAgent: 'my-app/1.0',
);| Property | Covers |
|---|---|
emails |
send, sendTemplate, sendBatch, preview, verify, status, retry, list, get |
bounces |
list, record |
suppressions |
list, add, remove |
webhooks |
list, create, delete, deliveries |
templates |
CRUD, versions, localizations, preview, sendTest, import/export |
languages, stylesheets |
CRUD |
domains |
add, list, get, verify, delete |
smtpServers, smtpCredentials |
CRUD, test, revoke |
subscribers |
CRUD, JSON and CSV bulk import |
subscriberLists |
CRUD, members, segments, subscribe/unsubscribe/resubscribe |
unsubscribeLists, contacts |
CRUD / read |
campaigns |
CRUD, send, pause, resume, cancel, duplicate, messages, analytics |
analytics |
emails, dashboard, providers, dashboardStats |
forms |
CRUD, rotateKey, snippet, nonce, public submit |
messages, messageFilters |
list, triage, reply, attachments; filter CRUD and dry-run |
inbound |
list, get, retry, raw .eml, attachments |
apiKeys |
create, list, get, revoke, delete |
workspaces |
CRUD, members, invitations, settings, SSO, audit log, export/import, GDPR |
users |
profile, password, 2FA, sessions, settings, notifications (session credential) |
auth |
login, register, password reset, email verification, SSO discovery |
admin |
users, plans, shared servers, domains, settings, announcements, events, metrics |
system |
info, healthz, readyz |
Every method returns the decoded data from the API envelope; list methods
return the whole envelope, so pageable is reachable alongside the rows.
$posta->emails->sendTemplate([
'template' => 'welcome',
'to' => ['user@example.com'],
'template_data' => ['name' => 'Ada'],
]);
$batch = $posta->emails->sendBatch([
'template' => 'welcome',
'recipients' => [
['email' => 'a@example.com', 'template_data' => ['name' => 'Ada']],
['email' => 'b@example.com', 'template_data' => ['name' => 'Grace']],
],
]);
echo "{$batch['sent']} sent, {$batch['failed']} failed\n";Validate without sending by passing true as the second argument:
$report = $posta->emails->send($request, true);Reference a Posta-managed unsubscribe list and Posta mints the signed one-click URL, recording opt-outs against that list alone:
$posta->emails->send([
'from' => 'news@example.com',
'to' => ['user@example.com'],
'subject' => 'This week',
'html' => '<p>…</p>',
'unsubscribe' => ['list_id' => 7],
]);$tpl = $posta->templates->create(['name' => 'welcome', 'default_language' => 'en']);
$ver = $posta->templates->createVersion($tpl['id']);
$posta->templates->createLocalization($tpl['id'], $ver['id'], [
'language' => 'en',
'subject_template' => 'Welcome, {{.name}}',
'html_template' => '<h1>Welcome, {{.name}}</h1>',
]);
$posta->templates->activateVersion($tpl['id'], $ver['id']);$camp = $posta->campaigns->create([
'name' => 'Launch',
'subject' => "We're live",
'from_email' => 'news@example.com',
'list_id' => $listId,
'template_id' => $tpl['id'],
]);
$posta->campaigns->send($camp['id']);
$stats = $posta->campaigns->analytics($camp['id']);
echo "open rate {$stats['analytics']['open_rate']}%\n";page is zero-based; omitting size lets the server apply its default.
$page = $posta->emails->list(['page' => 0, 'size' => 50, 'sort' => '-created_at']);
echo $page['pageable']['total_elements'] . "\n";
foreach ($page['data'] as $email) {
echo $email['subject'] . "\n";
}Posta signs each delivery with HMAC-SHA256 over the raw body, in the
X-Posta-Signature header as sha256=<hex>. Verify against the exact bytes
received — re-serializing the JSON changes them:
use Posta\WebhookEvent;
$raw = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_POSTA_SIGNATURE'] ?? null;
if (!WebhookEvent::verifySignature($raw, $sig, $secret)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
switch ($event['event']) {
case WebhookEvent::EMAIL_SENT:
error_log("delivered: {$event['email_id']}");
break;
case WebhookEvent::EMAIL_FAILED:
error_log("failed: {$event['email_id']}");
break;
}
http_response_code(200);Event constants live on Posta\WebhookEvent: EMAIL_SENT, EMAIL_FAILED,
EMAIL_INBOUND, EMAIL_UNSUBSCRIBED, EMAIL_COMPLAINED, CAMPAIGN_STARTED,
CAMPAIGN_COMPLETED, MESSAGE_RECEIVED, MESSAGE_SPAM.
$form = $posta->forms->create([
'name' => 'Contact',
'allowed_origins' => ['https://example.com'],
'strict_origin' => true,
'notify_emails' => ['team@example.com'],
]);
$snippet = $posta->forms->snippet($form['id']);
echo $snippet['html'];
$inbox = $posta->messages->list(['state' => 'new']);Non-2xx responses throw PostaException, carrying the status and the decoded
error envelope:
use Posta\PostaException;
try {
$posta->emails->send($request);
} catch (PostaException $err) {
error_log("posta {$err->getStatusCode()}: " . ($err->getErrorInfo()['message'] ?? ''));
if ($err->isRateLimited()) {
retryLater();
}
}Methods cover the common cases: isNotFound(), isUnauthorized(),
isForbidden(), isRateLimited(), plus getErrorCode() for the API's
structured error code.
Apache-2.0