Laravel integration guide

This guide wires OpenContact into a Laravel application end to end:

  1. Configure credentials
  2. A tiny API client
  3. Send contacts when users sign up
  4. Read contacts and events
  5. Receive webhooks (with signature verification)
  6. Subscribe your webhook endpoint
  7. Test it

You’ll need two values from OpenContact:

  1. An API key from Settings → Developers → API keys. Grant only the abilities your app needs.
  2. The project’s seven-character Project ID, shown on that project’s Overview dashboard.

A webhook signing secret is only needed if your Laravel app receives events from OpenContact.

Prefer a package? The official SDK does all of the below for you:

composer require emailroute/laravel

Set EMAILROUTE_API_KEY and EMAILROUTE_PROJECT_ID, then use the EmailRoute facade. If you also need webhooks, follow Receive webhooks to obtain EMAILROUTE_WEBHOOK_SECRET. The guide below shows the equivalent integration by hand.

1. Configure {#1-configure}

Add an OpenContact block to config/services.php:

'emailroute' => [
    'base_url' => env('EMAILROUTE_BASE_URL', 'https://app.opencontact.dev/api/v1'),
    'api_key' => env('EMAILROUTE_API_KEY'),
    'project' => env('EMAILROUTE_PROJECT_ID'),
    'webhook_secret' => env('EMAILROUTE_WEBHOOK_SECRET'),
],

And your .env:

EMAILROUTE_API_KEY=your_api_key
EMAILROUTE_PROJECT_ID=AB12CD3

That is enough to send and read contacts. Do not add EMAILROUTE_WEBHOOK_SECRET unless the app will receive webhooks.

2. A tiny client {#2-a-tiny-client}

A thin wrapper over Laravel’s HTTP client is all you need. app/Services/EmailRoute.php:

<?php

namespace App\Services;

use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;

class EmailRoute
{
    public function __construct(
        private string $baseUrl,
        private string $apiKey,
        private string $project,
    ) {}

    /** Create or update a contact (upserted by email). */
    public function upsertContact(array $contact): array
    {
        return $this->client()->post("/projects/{$this->project}/contacts", $contact)->throw()->json();
    }

    /** List recent contacts. */
    public function contacts(): array
    {
        return $this->client()->get("/projects/{$this->project}/contacts")->throw()->json();
    }

    /** Read the event stream (filter by type, since, limit). */
    public function events(array $query = []): array
    {
        return $this->client()->get("/projects/{$this->project}/events", $query)->throw()->json();
    }

    /** Subscribe a URL to outbound events. Returns the signing secret once. */
    public function createWebhook(string $url, array $events): array
    {
        return $this->client()->post("/projects/{$this->project}/webhooks", compact('url', 'events'))->throw()->json();
    }

    private function client(): PendingRequest
    {
        return Http::baseUrl($this->baseUrl)
            ->withToken($this->apiKey)
            ->acceptJson()
            ->retry(2, 200)
            ->timeout(10);
    }
}

Bind it as a singleton in app/Providers/AppServiceProvider.php:

use App\Services\EmailRoute;

public function register(): void
{
    $this->app->singleton(EmailRoute::class, fn () => new EmailRoute(
        config('services.emailroute.base_url'),
        config('services.emailroute.api_key'),
        config('services.emailroute.project'),
    ));
}

3. Send contacts when users sign up {#3-send-contacts}

Push contacts in a queued job so a slow API call never blocks the request:

<?php

namespace App\Jobs;

use App\Services\EmailRoute;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;

class SyncContactToEmailRoute implements ShouldQueue
{
    use Queueable;

    public int $tries = 3;
    public array $backoff = [10, 60, 300];

    public function __construct(public array $contact) {}

    public function handle(EmailRoute $emailRoute): void
    {
        $emailRoute->upsertContact($this->contact);
    }
}

Dispatch it from wherever a contact is created — e.g. after registration:

use App\Jobs\SyncContactToEmailRoute;

public function store(Request $request)
{
    $data = $request->validate([
        'email' => ['required', 'email'],
        'name' => ['nullable', 'string'],
        'plan' => ['nullable', 'string'],
    ]);

    $user = User::create($data);

    SyncContactToEmailRoute::dispatch([
        'email' => $user->email,
        'first_name' => $user->name,
        'source' => 'app-signup',
        'tags' => ['app-user'],
        'custom_fields' => ['plan' => $user->plan ?? 'free'],
    ])->afterCommit();

    return redirect()->route('dashboard');
}

Accepted contact fields: email (required), first_name, last_name, phone, company, country, tags[], source, status, custom_fields{}, consent{}. Contacts are upserted by normalized email, so re-sending the same email updates rather than duplicates.

Public forms without an API key: for a plain marketing form, skip the API key entirely and POST to a source ingest URL: Http::post("https://app.opencontact.dev/ingest/{$sourceToken}", ['email' => $email]). The token is the credential.

4. Read contacts and events {#4-read-data}

$emailRoute = app(\App\Services\EmailRoute::class);

// Recent contacts
$contacts = $emailRoute->contacts();

// Everyone who unsubscribed in the last day
$events = $emailRoute->events([
    'type' => 'contact.unsubscribed',
    'since' => now()->subDay()->toIso8601String(),
    'limit' => 100,
]);

5. Receive webhooks (with signature verification) {#5-receive-webhooks}

OpenContact POSTs a signed request to your URL whenever a subscribed event fires. Verify the signature in middleware so forged requests are rejected.

app/Http/Middleware/VerifyEmailRouteSignature.php:

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;

class VerifyEmailRouteSignature
{
    public function handle(Request $request, Closure $next)
    {
        $secret = config('services.emailroute.webhook_secret');
        abort_unless(is_string($secret) && trim($secret) !== '', 503, 'EmailRoute webhook secret is not configured.');

        // Header format: "t=<unix_ts>,v1=<hmac_sha256(t.body, secret)>"
        parse_str(str_replace(',', '&', (string) $request->header('X-EmailRoute-Signature')), $sig);
        $timestamp = $sig['t'] ?? null;
        $provided = $sig['v1'] ?? '';

        // Reject stale requests (replay protection), then verify the signature.
        abort_if(! $timestamp || abs(time() - (int) $timestamp) > 300, 401, 'Stale signature.');
        $expected = hash_hmac('sha256', $timestamp.'.'.$request->getContent(), $secret);
        abort_unless(hash_equals($expected, $provided), 401, 'Invalid signature.');

        return $next($request);
    }
}

The controller — a match over the event type. app/Http/Controllers/EmailRouteWebhookController.php:

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class EmailRouteWebhookController extends Controller
{
    public function __invoke(Request $request)
    {
        $deliveryId = $request->input('id'); // stable across retries — use it to dedupe
        $event = $request->input('event');
        $contact = $request->input('data'); // { id, email, first_name, last_name, status }

        // Skip anything you've already handled.
        if (Cache::has("emailroute:{$deliveryId}")) return response()->json(['ok' => true]);
        Cache::put("emailroute:{$deliveryId}", true, now()->addDay());

        match ($event) {
            'contact.created' => $this->onCreated($contact),
            'contact.unsubscribed',
            'contact.bounced',
            'contact.complained' => $this->onSuppressed($contact),
            'contact.status_changed' => $this->onStatusChanged($contact),
            default => null,
        };

        return response()->json(['ok' => true]);
    }

    private function onSuppressed(array $contact): void
    {
        // Mirror the opt-out into your own database so you never email them.
        User::where('email', $contact['email'])->update(['email_opted_in' => false]);
    }

    private function onCreated(array $contact): void { /* ... */ }
    private function onStatusChanged(array $contact): void { /* ... */ }
}

Register the route in routes/api.php (the api group has no CSRF, which is what you want for machine-to-machine calls):

use App\Http\Controllers\EmailRouteWebhookController;
use App\Http\Middleware\VerifyEmailRouteSignature;

Route::post('/webhooks/emailroute', EmailRouteWebhookController::class)
    ->middleware(VerifyEmailRouteSignature::class);

Your endpoint is now https://your-app.com/api/webhooks/emailroute. Return a 2xx quickly; OpenContact retries with backoff on any non-2xx.

6. Subscribe your webhook endpoint {#6-subscribe}

Choose the URL that matches your implementation:

  • If you installed the OpenContact Laravel package, it registers POST /webhooks/emailroute, so use https://your-app.com/webhooks/emailroute.
  • If you followed the manual routes/api.php example in step 5, use https://your-app.com/api/webhooks/emailroute.

On the OpenContact Webhooks page:

  1. Under Outbound webhooks, enter the matching URL above.
  2. Select the events your app needs.
  3. Click Add webhook.
  4. Copy the signing secret shown once into your Laravel app:
EMAILROUTE_WEBHOOK_SECRET=whsec_...

You can alternatively subscribe programmatically with an API key that has webhooks:manage:

$result = app(\App\Services\EmailRoute::class)->createWebhook(
    url: 'https://your-app.com/webhooks/emailroute', // package route; use /api/webhooks/emailroute for the manual route
    events: ['contact.created', 'contact.unsubscribed', 'contact.bounced', 'contact.complained'],
);

// Store $result['secret'] as EMAILROUTE_WEBHOOK_SECRET. It is shown only once.

7. Test it {#7-test}

Contact sync, with the HTTP client faked:

use Illuminate\Support\Facades\Http;

Http::fake(['*/contacts' => Http::response(['id' => 1, 'email' => '[email protected]'], 201)]);

app(\App\Services\EmailRoute::class)->upsertContact(['email' => '[email protected]']);

Http::assertSent(fn ($r) => str_ends_with($r->url(), '/contacts') && $r['email'] === '[email protected]');

The webhook receiver, with a correctly-signed body:

$timestamp = time();
$body = json_encode(['id' => 'evt_1', 'event' => 'contact.unsubscribed', 'data' => ['email' => '[email protected]']]);
$signature = 't='.$timestamp.',v1='.hash_hmac('sha256', $timestamp.'.'.$body, config('services.emailroute.webhook_secret'));

$this->call('POST', '/api/webhooks/emailroute', [], [], [],
    ['HTTP_X-EmailRoute-Signature' => $signature, 'CONTENT_TYPE' => 'application/json'],
    $body,
)->assertOk();

A bad signature should 401:

$this->call('POST', '/api/webhooks/emailroute', [], [], [],
    ['HTTP_X-EmailRoute-Signature' => 't='.time().',v1=nope', 'CONTENT_TYPE' => 'application/json'],
    json_encode(['event' => 'contact.created', 'data' => []]),
)->assertStatus(401);

That’s the whole loop: send contacts in, receive events back, verified.

Still stuck?

Send us your project ID and what you saw. Contact support.