Laravel integration guide
This guide wires OpenContact into a Laravel application end to end:
- Configure credentials
- A tiny API client
- Send contacts when users sign up
- Read contacts and events
- Receive webhooks (with signature verification)
- Subscribe your webhook endpoint
- Test it
You’ll need two values from OpenContact:
- An API key from Settings → Developers → API keys. Grant only the abilities your app needs.
- 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/laravelSet
EMAILROUTE_API_KEYandEMAILROUTE_PROJECT_ID, then use theEmailRoutefacade. If you also need webhooks, follow Receive webhooks to obtainEMAILROUTE_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 usehttps://your-app.com/webhooks/emailroute. - If you followed the manual
routes/api.phpexample in step 5, usehttps://your-app.com/api/webhooks/emailroute.
On the OpenContact Webhooks page:
- Under Outbound webhooks, enter the matching URL above.
- Select the events your app needs.
- Click Add webhook.
- 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' => 'a@x.com'], 201)]);
app(\App\Services\EmailRoute::class)->upsertContact(['email' => 'a@x.com']);
Http::assertSent(fn ($r) => str_ends_with($r->url(), '/contacts') && $r['email'] === 'a@x.com');
The webhook receiver, with a correctly-signed body:
$timestamp = time();
$body = json_encode(['id' => 'evt_1', 'event' => 'contact.unsubscribed', 'data' => ['email' => 'a@x.com']]);
$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.