This tutorial uses AgentiSend, which I build — so treat the walkthrough as a first-person account of my own API rather than a neutral review. Everything below is a request you can run yourself against the published contract, and the last section names three alternatives with one factual sentence each.
The goal: a verified sending domain, one email sent from PHP and from Node.js, an API key with a ceiling on it, a refusal you trigger on purpose and read, and a webhook endpoint that verifies its own signatures. About twenty minutes if your DNS propagates quickly.
What you need
- PHP 8 with the curl extension (every standard install has it), or Node 20+
- A domain you can edit DNS for
- An API key from the console
Set the key once in your shell so nothing below hard-codes it:
export AGENTISEND_API_KEY="your key"
Step 1: Verify a sending domain
Send from a subdomain, not the apex. mail.yourdomain.com or notifications.yourdomain.com keeps the reputation of your receipts separate from everything else the company sends — and the reverse, which is the direction that actually bites.
Add the domain:
curl -X POST https://api.agentisend.com/domains \
-H "Authorization: Bearer $AGENTISEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "mail.yourdomain.com"}'
region is optional and defaults to us (Oregon); pass "region": "eu" for Helsinki. It is a data-residency choice and it cannot be changed later, so pick it now.
Then read the records back with GET /domains/{id} and publish them at your registrar. Three are required:
- Record Type DKIM TXT:
as1._domainkeywith valuev=DKIM1; k=rsa; p=…— the generated key - Return path MX:
send feedback.agentisend-dns.com, priority 10 - Return path TXT:
send v=spf1 include:_spf.agentisend-dns.com -all
Two more are recommended and never block verification: an SPF TXT on the domain root with the same value, and a DMARC TXT on _dmarc starting at v=DMARC1; p=none; with a reporting address. Start at p=none, read the reports for a week, then move to quarantine.
Two things go wrong here more than everything else combined:
- A DKIM key truncated at 255 characters. DNS splits long TXT values into 255-byte strings, and some registrar editors keep only the first. The API returns the record as a value_strings array — paste every entry, or use the quoted value_bind form.
- A second SPF record. One name may carry exactly one SPF record; two invalidate both. If the root already has one, merge into it rather than adding another:
v=spf1 include:_spf.agentisend-dns.com include:_spf.yourotherprovider.example -all.
Then re-check:
curl -X POST https://api.agentisend.com/domains/{id}/verify \
-H "Authorization: Bearer $AGENTISEND_API_KEY"
Verification is a DNS read, so it is only as fast as your registrar. The domain is verified once DKIM and both return-path records resolve. Until then, a send from it is refused with domain_not_verified, and the refusal names the endpoint to call next.
You do not have to wait to write code. Address the mail to anything ending in @simulator.agentisend.com and nothing leaves the building — it costs nothing and any well-formed From address is accepted. That is the address to use in your test suite forever, not only today.
Step 2: Install the client
Node.js. The package is on npm:
npm install agentisend
PHP. There is no Packagist package yet — an honest gap, and the reason this tutorial's PHP is written against the HTTP API directly. The generated PHP client in the repository has no Composer dependencies at all; it uses curl, because every PHP install has curl. So rather than ask you to vendor a file, here is the same thing as thirty lines you can paste into your project and own:
<?php
// AgentiSendClient.php — no dependencies, PHP 8+.
final class AgentiSendError extends RuntimeException {
public function __construct(
public readonly string $code,
string $message,
public readonly string $fix,
public readonly int $status,
) {
parent::__construct($message);
}
}
final class AgentiSendClient {
public function __construct(
private readonly string $apiKey,
private readonly string $baseUrl = 'https://api.agentisend.com',
) {}
public function request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array {
$headers = [
'Authorization: Bearer ' . $this->apiKey,
'Content-Type: application/json',
];
if ($idempotencyKey !== null) {
$headers[] = 'Idempotency-Key: ' . $idempotencyKey;
}
$ch = curl_init($this->baseUrl . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body, JSON_THROW_ON_ERROR));
}
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
$decoded = json_decode($raw ?: '{}', true) ?? [];
if ($status >= 400) {
$error = $decoded['error'] ?? [];
throw new AgentiSendError(
$error['code'] ?? 'unknown',
$error['message'] ?? "HTTP $status",
$error['fix'] ?? '',
$status,
);
}
return $decoded;
}
}
Every refused request carries the same four fields — code, message, fix and docs_url — which is why the exception above can be that small.
Step 3: Send one email
PHP:
<?php
require 'AgentiSendClient.php';
$client = new AgentiSendClient(getenv('AGENTISEND_API_KEY'));
$sent = $client->request('POST', '/emails', [
'from' => 'Acme <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Your receipt',
'text' => 'Thanks. The details are on your account.',
], idempotencyKey: 'receipt/order-4182');
echo $sent['id'], PHP_EOL;
Node.js:
import { AgentiSend } from 'agentisend';
const mail = new AgentiSend(process.env.AGENTISEND_API_KEY);
const { id } = await mail.emails.send(
{
from: 'Acme <[email protected]>',
to: '[email protected]',
subject: 'Your receipt',
text: 'Thanks. The details are on your account.',
},
{ idempotencyKey: 'receipt/order-4182' },
);
console.log(id);
Store that id against the order row. Every later question — did it arrive, why was it held — is answered by GET /emails/{id}.
Note the idempotency key, and note what it is derived from. receipt/order-4182 names the thing being done. It is not a timestamp and not a UUID generated at call time, because a retry after a timeout has to produce the same key for the replay to work. Derive it from the thing, never from the moment. Keys are accepted on every mutating endpoint and remembered for 7 days.
Step 4: Put a ceiling on the key
This is the step people skip, and it is the one that matters the moment anything other than your own code holds the credential.
Mint a key scoped to sending, attach a limit, and hand over only that:
<?php
$owner = new AgentiSendClient(getenv('AGENTISEND_API_KEY')); // yours; the agent never sees it
$key = $owner->request('POST', '/api-keys', [
'name' => 'support-agent',
'permission' => 'sending_access',
]);
$owner->request('PATCH', "/limits/keys/{$key['id']}", [
'budget_per_period' => 50,
'period' => 'daily',
'rate_ceiling_per_minute' => 10,
]);
$agentKey = $key['token']; // this is what the agent holds
sending_access cannot read your message log, touch your domains, or mint further keys — and cannot raise its own ceiling. The two numbers are messages per period and messages per rolling minute; both count messages, not money.
A budget is enforced before the send, not reported after it. Nothing queues up behind the refusal and nothing is silently dropped. And because it is attached to the credential rather than to your code, an agent that rewrites its own tool wrapper still cannot get past it.
Two more controls you get for free here. limit.warning fires at 80% of the key budget, so you hear about it while there is still something to do. And one request pauses every key on the account:
curl -X POST https://api.agentisend.com/limits/kill-all \
-H "Authorization: Bearer $AGENTISEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"reason": "invoice-bot loop"}'
Sends are then refused with kill_switch_active; queued sends stay queued; POST /limits/resume-all is a second, deliberate request. Both write an audit row with the reason you typed.
Step 5: Trigger a refusal and read it
Do not wait for production to show you this. Send past the ceiling on purpose:
<?php
$agent = new AgentiSendClient($agentKey);
$message = [
'from' => 'Acme <[email protected]>',
'to' => ['[email protected]'],
'subject' => 'Ticket 4182, we are looking into it',
'text' => 'Someone from support will reply within the hour.',
];
for ($i = 0; $i < 10; $i++) {
try {
$agent->request('POST', '/emails', $message);
} catch (AgentiSendError $refusal) {
echo $refusal->code, PHP_EOL; // approval_required
echo $refusal->getMessage(), PHP_EOL;
echo $refusal->fix, PHP_EOL;
break;
}
}
The same body to the same recipient, over and over, is the exact shape of an agent stuck in a retry loop. It does not reach the budget first — the loop guard catches it, holds the send rather than delivering it, and answers approval_required. The held send waits in the approvals queue with the duplicate it matched, so you can see why it stopped, and GET /agent-actions lists it for a person to approve or reject. The key that asked cannot approve itself.
Had it run past the ceiling instead, the code would be agent_budget_exceeded and the fix would name PATCH /limits/keys/{id}.
Return code and fix to whatever called you. If an agent is the caller, this is the difference between a model that retries forever and a model that stops: "Forbidden" invites another attempt, while a sentence naming the call that repairs the problem does not. The runnable end-to-end version of this — mint, cap, loop, read the refusal — is the agent with a budget and a loop guard guide.
Step 6: Add a webhook and verify its signature
Subscribe an endpoint to the events you care about:
<?php
$hook = $owner->request('POST', '/webhooks', [
'endpoint' => 'https://yourapp.example/webhooks/email',
'events' => ['email.delivered', 'email.bounced', 'email.complained', 'limit.warning'],
]);
// Store $hook['secret'] — you need it to verify every delivery.
Each delivery carries x-agentisend-timestamp and x-agentisend-signature, formatted t=<unix seconds>,v1=<hmac hex>, where the HMAC-SHA256 is computed over timestamp.body. Rotation sends more than one v1 in the same header so a receiver holding either secret still verifies.
<?php
function verifyAgentiSendSignature(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool {
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', trim($piece), 2), 2, '');
$parts[$k][] = $v;
}
$timestamp = (int) ($parts['t'][0] ?? 0);
if ($timestamp === 0 || abs(time() - $timestamp) > $toleranceSeconds) {
return false; // outside the window: a captured delivery replayed at you
}
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
foreach ($parts['v1'] ?? [] as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
return false;
}
// In the controller:
$rawBody = file_get_contents('php://input'); // raw, always
$header = $_SERVER['HTTP_X_AGENTISEND_SIGNATURE'] ?? '';
if (!verifyAgentiSendSignature($rawBody, $header, getenv('AGENTISEND_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($rawBody, true); // {id, type, occurred_at, account_id, data}
// Store it, then answer. Deduplicate on $event['id'].
http_response_code(200);
Three rules that are not optional:
- Read the raw body. A framework that parses JSON and re-serialises it changes bytes, and a signature over re-serialised JSON verifies at random. In Laravel that is
$request->getContent(), not$request->all(). - Check the timestamp. Without it, a captured delivery can be replayed at you forever. The tolerance is five minutes.
- Deduplicate on the event id. Delivery is at-least-once: a retry after your 2xx was lost, or a replay you asked for, produces the same id twice.
Return 2xx as soon as the event is stored and do the work afterwards. A slow consumer looks like a failing one, a failing one gets retried, and the retries make it slower.
Alternatives
Three products worth knowing before you commit, described from their own documentation:
- Resend is the developer-default in this category, with the largest ecosystem and a hosted MCP server at mcp.resend.com/mcp; its Idempotency-Key support covers POST /emails and the batch endpoint, with keys that expire after 24 hours.
- Postmark is built around transactional mail and splits credentials into server and account tokens, and its POSTMARK_API_TEST token validates a request without delivering it — useful in CI whichever provider you end up on.
- Mailgun describes itself as "the email infrastructure platform trusted by developers worldwide" and covers both SMTP integration and inbound routing, where you "define rules for handling incoming emails".
None of the three publishes a per-key send budget, a duplicate-send guard or an account-wide pause, which is the gap this tutorial is about — and if you are sending from application code you control rather than from an agent, that gap may not be one you need to fill. The row-by-row version of the first comparison is at AgentiSend vs Resend.
Numan Hamza builds AgentiSend.
