> ## Documentation Index
> Fetch the complete documentation index at: https://developers.nateq.io/llms.txt
> Use this file to discover all available pages before exploring further.

# PHP SDK

> Send and read email from PHP, with first-class Laravel support and no required dependencies.

The official PHP client for the Nateq API. Framework-agnostic core, with an
auto-discovered Laravel service provider and facade. Zero required runtime
dependencies, so it won't fight your app over a Guzzle version.

<CardGroup cols={2}>
  <Card title="Packagist" icon="package" href="https://packagist.org/packages/nateq/sdk">
    nateq/sdk
  </Card>

  <Card title="GitHub" icon="git-branch" href="https://github.com/nateq-ai/sdk-php">
    Source and issues
  </Card>
</CardGroup>

## Requirements

* PHP 8.2 or newer
* `ext-curl` and `ext-json`
* Laravel 11 or 12 (optional — the SDK works without it)

## Install

```bash theme={null}
composer require nateq/sdk
```

In Laravel, the service provider and `Nateq` facade are auto-discovered. There's
nothing to register.

## Quickstart

Create an API key with the `emails:send` scope, then put it in your environment:

```bash theme={null}
NATEQ_API_KEY=tg_live_your_key_here
```

```php theme={null}
use Nateq\Sdk\Nateq;

$nateq = new Nateq(); // reads NATEQ_API_KEY

$result = $nateq->emails()->send(
    toEmails: ['customer@example.com'],
    subject: 'Welcome aboard',
    htmlBody: '<p>Thanks for signing up.</p>',
);

echo $result->id;
```

In Laravel, resolve the client instead of constructing it:

```php theme={null}
use Nateq\Sdk\Nateq;

class WelcomeMailer
{
    public function __construct(private Nateq $nateq) {}

    public function send(User $user): void
    {
        $this->nateq->emails()->send(
            toEmails: [$user->email],
            subject: 'Welcome aboard',
            htmlBody: view('mail.welcome', ['user' => $user])->render(),
        );
    }
}
```

## Authentication

The SDK reads `NATEQ_API_KEY` by default. Pass it explicitly to load it from a
secret manager, or to talk to more than one organization:

```php theme={null}
$nateq = new Nateq(apiKey: $secrets->get('nateq_api_key'));
```

The key is validated locally before any request, so a mistyped key fails
immediately rather than going out over the network.

| Scope         | Needed for           |
| ------------- | -------------------- |
| `emails:send` | `send()`             |
| `emails:read` | `get()` and `list()` |

<Warning>
  `var_dump()`, `json_encode()`, and framework error pages all redact the key, and
  serializing a client throws on purpose. But `print_r()` and `var_export()` have no
  redaction hook in PHP and **will** print it — don't pass the client to either.
</Warning>

## Sending email

`send()` returns once the API accepts the message. Delivery happens afterwards —
a successful return means Nateq took responsibility for it, not that it landed.

```php theme={null}
$result = $nateq->emails()->send(
    toEmails: ['a@example.com', 'b@example.com'],
    subject: 'Quarterly update',
    htmlBody: '<p>Hello</p>',
    plainTextBody: 'Hello',
    fromEmail: 'hello@yourdomain.com', // must be a verified address
    fromName: 'Acme Support',
    ccEmails: ['c@example.com'],
    bccEmails: ['d@example.com'],
    replyTo: 'support@yourdomain.com',
    ticketId: '…',
    attachmentIds: ['…'],
    headers: ['X-Campaign' => 'q3'],
);

$result->id;
$result->status; // EmailStatus::Pending
```

Provide `htmlBody`, `plainTextBody`, or both. Omit `fromEmail` and
`emailAddressId` to use your organization's default verified address.

## Reading email

```php theme={null}
$email = $nateq->emails()->get($result->id);

$email->status;       // EmailStatus::Delivered
$email->openCount;
$email->bounceReason;
```

`EmailStatus` is an enum with helpers, so you don't have to memorise which of the
nine statuses count as success:

```php theme={null}
if ($email->status->isFailure()) {   // bounced | failed | rejected
    Log::warning('Email did not arrive', ['reason' => $email->bounceReason]);
}

$email->status->isDelivered(); // delivered | opened | clicked
$email->status->isPending();   // pending | sending | sent
```

List sends, newest first. The result is countable and iterable:

```php theme={null}
use Nateq\Sdk\Types\EmailStatus;

$page = $nateq->emails()->list(
    status: EmailStatus::Bounced,
    toEmail: 'customer@example.com',
    limit: 25,
);

foreach ($page as $email) {
    echo $email->subject;
}

$page->total;
$page->hasMore();
$page->nextOffset(); // feed straight back into list(offset: …)
```

`limit` defaults to 50 and is capped at 100.

<Note>
  The API returns more fields than the SDK models. Anything not promoted to a
  property is still on `$email->raw`, so a new field works without an SDK upgrade.
</Note>

## Errors

Everything extends `NateqException`, so one `catch` covers the surface:

```php theme={null}
use Nateq\Sdk\Exceptions\NateqException;
use Nateq\Sdk\Exceptions\PermissionException;
use Nateq\Sdk\Exceptions\RateLimitException;
use Nateq\Sdk\Exceptions\ValidationException;

try {
    $nateq->emails()->send(/* … */);
} catch (ValidationException $e) {
    // 400/422 — the request was wrong. Don't retry it unchanged.
} catch (PermissionException $e) {
    $e->errorCode; // "INSUFFICIENT_SCOPE"
} catch (RateLimitException $e) {
    $e->retryAfter; // seconds
} catch (NateqException $e) {
    $e->status;
    $e->requestId; // quote this to support
}
```

| Exception                 | When                                                |
| ------------------------- | --------------------------------------------------- |
| `ValidationException`     | 400/422, or bad arguments caught before any request |
| `AuthenticationException` | 401 — key missing, malformed, revoked               |
| `PermissionException`     | 403 — key valid, but not allowed                    |
| `NotFoundException`       | 404                                                 |
| `RateLimitException`      | 429 — carries `retryAfter`                          |
| `ServerException`         | 5xx                                                 |
| `TimeoutException`        | request exceeded the timeout                        |
| `ConnectionException`     | DNS, TLS, socket, offline                           |
| `ConfigurationException`  | bad SDK setup — thrown before any request           |

See [Errors](/errors) for the underlying API codes.

## Retries and duplicate sends

Reads (`get`, `list`) retry automatically on timeouts, 5xx, and connection
failures, with exponential backoff that honours `Retry-After`.

**`send()` does not.** The API has no idempotency key, so a send that fails
*after* reaching the server may already have gone out. Replaying it could mail
your customer twice, and the SDK won't make that call for you.

The exception is a **429**, which the API raises before anything is sent — so it
is retried, because a duplicate is impossible.

If a `send()` throws `TimeoutException` or `ServerException`, the outcome is
genuinely unknown. Look it up before retrying:

```php theme={null}
use Nateq\Sdk\Exceptions\ServerException;
use Nateq\Sdk\Exceptions\TimeoutException;

try {
    $nateq->emails()->send(toEmails: [$user->email], subject: $subject, htmlBody: $html);
} catch (TimeoutException|ServerException $e) {
    $recent = $nateq->emails()->list(toEmail: $user->email, limit: 5);
    // decide based on what's there
}
```

## Laravel

Publish the config only if you want to edit it:

```bash theme={null}
php artisan vendor:publish --tag=nateq-config
```

```php theme={null}
// config/nateq.php
return [
    'api_key' => env('NATEQ_API_KEY'),
    'base_url' => env('NATEQ_BASE_URL', 'https://api.nateq.io/api/v1'),
    'timeout' => env('NATEQ_TIMEOUT', 30),
    'max_retries' => env('NATEQ_MAX_RETRIES', 2),
];
```

Inject the client, or use the facade:

```php theme={null}
use Nateq\Sdk\Laravel\Facades\Nateq;

Nateq::emails()->send(
    toEmails: ['customer@example.com'],
    subject: 'Welcome aboard',
    htmlBody: '<p>Thanks for signing up.</p>',
);
```

### Send from a queued job

Email is a network call to a third party. Doing it inline ties your response time
to ours.

```php theme={null}
class SendWelcomeEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(public User $user) {}

    // Do not retry blindly: the send may have succeeded before the failure.
    public $tries = 1;

    public function handle(Nateq $nateq): void
    {
        $nateq->emails()->send(
            toEmails: [$this->user->email],
            subject: 'Welcome aboard',
            htmlBody: view('mail.welcome', ['user' => $this->user])->render(),
        );
    }
}
```

<Warning>
  Resolve the client through `handle()`, as above — never store it on the job as a
  property. Job properties are serialized into the queue payload, and the client
  refuses to serialize precisely so your API key can't end up sitting in Redis in
  plain text.
</Warning>

If you change `NATEQ_API_KEY` and nothing happens, you have a cached config:

```bash theme={null}
php artisan config:clear
```

## Testing

Don't hit the API from your test suite. Implement `HttpClient` with a fake:

```php theme={null}
use Nateq\Sdk\Http\{HttpClient, Request, Response};

$fake = new class implements HttpClient {
    public array $requests = [];

    public function send(Request $request, float $timeoutSeconds): Response
    {
        $this->requests[] = $request;

        return new Response(201, [], json_encode([
            'id' => 'test-id', 'status' => 'pending', 'createdAt' => 'now',
        ]));
    }
};
```

In Laravel, bind it and the container-resolved client picks it up — facade
included:

```php theme={null}
$this->app->instance(HttpClient::class, $fake);

Nateq::emails()->send(toEmails: ['a@example.com'], subject: 'Hi', htmlBody: '<p>Hi</p>');

$this->assertCount(1, $fake->requests);
```

The same seam routes traffic through your own stack — Guzzle, Symfony HttpClient,
or an outbound proxy.
