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

# Process webhooks reliably

> Durable acceptance, deduplication, downloads and errors, with a complete Laravel example.

Talario delivers each webhook at least once and retries for 24 hours. Your endpoint decides with its answer whether Talario may stop retrying. A reliable endpoint follows four rules:

1. **Verify** the signature before anything else.
2. **Store durably**, then answer `2xx`. Only answer `2xx` when the delivery survives a crash or restart of your application.
3. **Deduplicate** by `delivery_id`, and process each message and recipient once.
4. **Process asynchronously**: download files and run your business logic in a queued job, not in the HTTP request.

## Durable acceptance

Answering `2xx` tells Talario that your application has taken over the message. If you answer `2xx` and then lose the delivery, Talario does not send it again.

Durable means, for example, a row in your database and a job in a persistent queue (database, Redis with persistence, SQS). An in-memory queue, a `sync` queue or a job dispatched after the response does not count.

What you must store before answering depends on whether your application has an API key with receiving access:

| | With API key | Without API key |
| - | - | - |
| Before `2xx` | The verified JSON payload | The payload **and** all files you need |
| Files | Download them in a job; request [new links](/receiving/downloads#get-a-new-link) when they expired | Download them in the request, within the link validity |

We recommend an API key that may **Read received messages**. Your endpoint can then answer quickly, and a failing download does not cause webhook retries.

If storing fails, answer with `5xx`. Talario retries later.

## Deduplication

Use two keys:

| Key | Purpose |
| - | - |
| `delivery_id` | Recognize a repeated delivery. Store it with a unique index, and answer `2xx` again for a delivery you already stored. |
| `id` + `recipient` | Process a message for a recipient once, even if it reaches you through several endpoints. |

The external `Message-ID` header is chosen by the sender. It can be missing, repeated or forged; never use it as the only key.

Deduplication must hold for parallel requests. Use unique database constraints, not a cache entry or a check before the insert.

## Laravel example

This example stores each delivery in a table, answers `204`, and processes the message in a queued job. It handles [payload version 1](/webhooks/payload-v1); the changes for [version 2](/webhooks/payload-v2) are noted at the end.

It uses the [`VerifyTalarioWebhook` middleware](/webhooks/signatures#laravel) and the configuration from the signature guide.

### Migration

```php database/migrations/2026_10_21_000000_create_talario_deliveries_table.php theme={null}
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('talario_deliveries', function (Blueprint $table) {
            $table->id();
            $table->uuid('delivery_id')->unique();
            $table->uuid('message_id');
            $table->string('recipient');
            $table->json('payload');
            $table->timestamp('processed_at')->nullable();
            $table->timestamps();
        });

        Schema::create('inbound_emails', function (Blueprint $table) {
            $table->id();
            $table->uuid('talario_message_id');
            $table->string('recipient');
            $table->string('envelope_from');
            $table->string('storage_path')->nullable();
            $table->timestamps();

            $table->unique(['talario_message_id', 'recipient']);
        });
    }
};
```

### Controller

```php app/Http/Controllers/TalarioWebhookController.php theme={null}
<?php

namespace App\Http\Controllers;

use App\Jobs\ProcessTalarioDelivery;
use App\Models\TalarioDelivery;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class TalarioWebhookController extends Controller
{
    public function __invoke(Request $request): Response
    {
        $payload = $request->json()->all();
        $message = $payload['message'] ?? $payload; // version 1 nests the message, version 2 does not

        $delivery = TalarioDelivery::createOrFirst(
            ['delivery_id' => $request->header('X-Talario-Delivery-Id')],
            [
                'message_id' => $message['id'],
                'recipient' => $message['recipient'],
                'payload' => $payload,
            ],
        );

        if ($delivery->processed_at === null) {
            ProcessTalarioDelivery::dispatch($delivery);
        }

        return response()->noContent();
    }
}
```

`createOrFirst` relies on the unique index, so parallel deliveries with the same `delivery_id` store one row. Dispatching again for a stored but unprocessed delivery is harmless because the job is idempotent.

> [!NOTE]
>
> Use a persistent queue connection such as `database` or `redis`, not `sync`. Dispatching stores the job before the response is sent; if the queue is not available, the request fails with `5xx` and Talario retries.

### Model

```php app/Models/TalarioDelivery.php theme={null}
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class TalarioDelivery extends Model
{
    protected $fillable = ['delivery_id', 'message_id', 'recipient', 'payload', 'processed_at'];

    protected function casts(): array
    {
        return [
            'payload' => 'array',
            'processed_at' => 'datetime',
        ];
    }
}
```

### Job

```php app/Jobs/ProcessTalarioDelivery.php theme={null}
<?php

namespace App\Jobs;

use App\Models\TalarioDelivery;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\File;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use RuntimeException;

class ProcessTalarioDelivery implements ShouldQueue
{
    use Queueable;

    public int $tries = 10;

    public array $backoff = [60, 300, 900, 3600];

    public function __construct(public TalarioDelivery $delivery) {}

    public function handle(): void
    {
        if ($this->delivery->fresh()->processed_at !== null) {
            return;
        }

        $message = $this->delivery->payload['message'];
        $path = "inbound/{$message['id']}.eml";

        if (! Storage::exists($path)) {
            $this->download($message, $path);
        }

        DB::transaction(function () use ($message, $path) {
            // Process each message and recipient once, even across several endpoints.
            DB::table('inbound_emails')->insertOrIgnore([
                'talario_message_id' => $message['id'],
                'recipient' => $message['recipient'],
                'envelope_from' => $message['envelope_from'],
                'storage_path' => $path,
                'created_at' => now(),
                'updated_at' => now(),
            ]);

            $this->delivery->update(['processed_at' => now()]);
        });

        // Parse the stored .eml and run your business logic here, or dispatch another job.
    }

    /**
     * Download the original message, using a new link when the one from the webhook expired.
     */
    private function download(array $message, string $path): void
    {
        $url = $this->delivery->payload['download']['url'];

        if (now()->addMinute()->greaterThanOrEqualTo($this->delivery->payload['download']['expires_at'])) {
            $url = Http::baseUrl('https://talario.eu/api/v1')
                ->withToken(config('services.talario.api_key'))
                ->acceptJson()
                ->post("receiving/messages/{$message['id']}/download-url")
                ->throw()
                ->json('url');
        }

        $temporaryPath = tempnam(sys_get_temp_dir(), 'talario');

        try {
            Http::timeout(300)->sink($temporaryPath)->get($url)->throw();

            if (filesize($temporaryPath) !== $message['size_bytes'] || ! hash_equals($message['sha256'], hash_file('sha256', $temporaryPath))) {
                throw new RuntimeException("The download of message {$message['id']} does not match its checksum.");
            }

            Storage::putFileAs(dirname($path), new File($temporaryPath), basename($path));
        } finally {
            unlink($temporaryPath);
        }
    }
}
```

A failed download makes the job fail and retry with Laravel's backoff, without involving Talario. The download link is used until it expires; afterwards the job requests a new one. Requests for a new link fail with `410 message_expired` after the retention period, so process messages well within 7 days.

### Processing payload version 2

To process [payload version 2](/webhooks/payload-v2), adapt the example:

* The message fields are at the top level of the payload instead of below `message` (the controller above already handles both).
* Store subject, text, HTML and headers from the payload instead of parsing the `.eml`.
* Download each entry of `attachments` with its `url`, check `size` and `sha256`, and request a new link with `POST /receiving/messages/{id}/attachments/{attachment_id}/download-url` when it expired.
* If `content_omitted` is `true`, read the full content with [`GET /content`](/receiving/parsed-content) and the `recipient` of the delivery.
* If `parse_status` is `failed`, process the message from the original in `download`.

## Errors in your application

| Situation | Answer |
| - | - |
| Signature invalid, timestamp too old, unknown key ID | `401`. Talario retries; check your secrets if this persists. |
| Delivery stored | `2xx`, also when it was stored before. |
| Database or queue not available | `5xx`. Talario retries. |
| Your business logic fails | Answer `2xx` once the delivery is stored, and handle the failure in your job. |
| Endpoint overloaded | `429` or `503` with `Retry-After`. |

If your endpoint was down for longer than 24 hours, deliveries are `failed`. List them with [`GET /receiving/messages`](/api-reference/introduction) and [retry](/webhooks/overview#manual-retries) them, or download the messages directly with the API.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.