# Outlevo quickstart for Laravel

For a Laravel app that pushes its own companies into Outlevo, lets Outlevo run approved outbound, and shows what happened in its own screens (Blade, Livewire or Filament). Everything runs on your server: the browser never sees an Outlevo key.

## 1. Keys

1. Sign in at https://app.outlevo.com and open **Developers**. Only an owner or admin sees **New test key** and **New live key**.
2. Start with a **test** key: it works on your sandbox (100 fake companies, no vendors, no credits).
3. For outbound (campaigns, leads), tick **Acts as me**. A key without it acts as a developer and gets `403 FORBIDDEN` on campaign writes. The member who creates it needs the rep, sales_approver, marketing_approver, admin or owner role.
4. A live key needs a second factor in the last 12 hours, the scopes it uses (e.g. `companies:read companies:write signals:write campaigns:read campaigns:write jobs:read webhooks:manage`) and three spend caps.

```dotenv
OUTLEVO_KEY=olv_test_...
OUTLEVO_WEBHOOK_SECRET=whsec_...   # returned once by POST /v1/webhooks
```

```php
// config/services.php
'outlevo' => ['key' => env('OUTLEVO_KEY'), 'webhook_secret' => env('OUTLEVO_WEBHOOK_SECRET')],
```

## 2. A client with idempotency and retries

```php
namespace App\Services;

use Illuminate\Http\Client\{ConnectionException, PendingRequest, RequestException, Response};
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;

class Outlevo
{
    private function http(): PendingRequest
    {
        return Http::baseUrl('https://api.outlevo.com')
            ->withToken(config('services.outlevo.key'))
            ->acceptJson()->timeout(15)
            // retries resend the same headers, so the Idempotency-Key below is reused, as Outlevo expects
            ->retry(3, 500, fn ($e) => $e instanceof ConnectionException
                || ($e instanceof RequestException && in_array($e->response->status(), [429, 502, 503], true)), throw: false);
    }

    public function get(string $path, array $query = []): Response
    {
        return $this->http()->get($path, $query);
    }

    /** POST/PUT/PATCH: one Idempotency-Key per operation. Pass your own key to make a queued job's retries safe too. */
    public function write(string $method, string $path, array $body = [], ?string $key = null): Response
    {
        return $this->http()->withHeaders(['Idempotency-Key' => $key ?? (string) Str::uuid()])->send($method, $path, ['json' => $body]);
    }
}
```

Errors are JSON: `{"error": {"code", "message", "fix", "details", "request_id"}}`. Log `request_id`; show `fix` to whoever can act on it.

## 3. Push companies with your data

```php
$res = app(Outlevo::class)->write('POST', '/v1/companies', [
    'domain' => $site->domain,
    'external_id' => (string) $site->id,                 // your id; find it again with ?external_id=
    'attributes' => [                                     // flat: string, number or boolean values
        'pages_exposed' => $audit->pages_exposed,
        'ai_bots_allowed' => $audit->ai_bots_allowed,
        'report_url' => route('reports.show', $audit),
    ],
], key: "site-{$site->id}-create");

$companyId = $res->status() === 409
    ? $res->json('error.details.company_id')               // COMPANY_EXISTS: it is already there
    : $res->json('id');

// later changes: attributes merge; null removes a key
app(Outlevo::class)->write('PATCH', "/v1/companies/{$companyId}", ['attributes' => ['pages_exposed' => 650]]);
```

Many at once: `POST /v1/companies/bulk` with up to 1,000 `records` (same fields). It answers 202 with a `job_id`; poll `GET /v1/jobs/{id}` from a queue job.

Tell Outlevo something happened, with the link and the numbers:

```php
app(Outlevo::class)->write('POST', '/v1/signals', [
    'company_id' => $companyId, 'source' => 'botscope', 'type' => 'audit_completed', 'weight' => 40,
    'observed_at' => now()->toIso8601String(), 'url' => route('reports.show', $audit),
    'data' => ['pages_exposed' => $audit->pages_exposed],
]);
```

## 4. Use your numbers in emails

Put the attributes your messages use in the outbound policy's `merge_fields`, e.g. `["pages_exposed", "report_url"]`: draft the policy with `POST /v1/policies` (type `outbound_policy`, with templates, footer and caps in its body), and a sales approver approves it in the portal. Then messages can say `{{pages_exposed}}`. Outlevo refuses any undeclared tag, and holds back a lead whose company has no value for a tag its messages use (`MERGE_FIELD_MISSING`) until you PATCH the attribute.

## 5. Receive webhooks

```php
// routes/web.php (or api.php)
Route::post('/webhooks/outlevo', OutlevoWebhookController::class)->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\VerifyCsrfToken::class]);
```

```php
namespace App\Http\Controllers;

use App\Jobs\HandleOutlevoEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class OutlevoWebhookController
{
    public function __invoke(Request $request)
    {
        $raw = $request->getContent();                       // the raw body: do not re-encode the JSON
        $parts = collect(explode(',', (string) $request->header('Outlevo-Signature')))
            ->map(fn ($p) => explode('=', trim($p), 2));
        $t = (int) optional($parts->first(fn ($p) => $p[0] === 't'))[1];
        abort_if(! $t || abs(time() - $t) > 300, 400, 'stale');
        $want = hash_hmac('sha256', $t.'.'.$raw, config('services.outlevo.webhook_secret'));
        // after a secret rotation the header has two v1 values for 24 hours; either may match
        abort_unless($parts->contains(fn ($p) => $p[0] === 'v1' && hash_equals($want, $p[1] ?? '')), 400, 'bad signature');

        $event = json_decode($raw, true);
        if (Cache::add('outlevo-event:'.$event['id'], 1, now()->addDay())) {   // deliveries can repeat
            HandleOutlevoEvent::dispatch($event);                               // answer 2xx fast; work in the queue
        }
        return response()->noContent();
    }
}
```

Subscribe once (a command or tinker), keep the secret it returns, and send a test:

```php
$sub = app(Outlevo::class)->write('POST', '/v1/webhooks', ['url' => 'https://your.app/webhooks/outlevo',
    'events' => ['company.created', 'company.qualified', 'lead.sent', 'lead.replied', 'lead.bounced', 'lead.unsubscribed', 'campaign.status_changed', 'job.completed']]);
// $sub['secret'] is shown once → OUTLEVO_WEBHOOK_SECRET
app(Outlevo::class)->write('POST', "/v1/webhooks/{$sub['id']}/test");   // { delivered, status_code }
```

Rotate later with `POST /v1/webhooks/{id}/rotate-secret`: the old secret keeps signing for 24 hours while you deploy the new one.

Locally, expose your app with a tunnel and subscribe that URL:

```bash
php artisan serve
cloudflared tunnel --url http://localhost:8000      # or: ngrok http 8000
```

## 6. Show what happened, in your own screens

```php
$stats = app(Outlevo::class)->get("/v1/campaigns/{$campaignId}/stats")->json();
// leads, pushed, sent, emails_sent, replied, bounced, hard_bounced, unsubscribed, reply_rate, bounce_rate, unsubscribe_rate

$leads = app(Outlevo::class)->get("/v1/campaigns/{$campaignId}/leads", ['external_id' => (string) $site->id])->json('data');
// per lead: state, emails_sent, first_sent_at, replied_at, bounced_at, unsubscribed_at, merge_values
```

Lists are paged: pass `limit` and the previous answer's `next_cursor` as `cursor`. In Filament or Livewire, load these after the page renders (`wire:init`) and cache them briefly: they are network calls. Prefer the `lead.*` webhooks for anything that must be up to date.

## 7. What a person does before anything sends

No API key can do these, by design. Plan for them before you switch your own sending off:

1. An ICP version is approved in the portal.
2. An admin connects Smartlead (portal → Integrations).
3. Mailboxes are warmed up with daily caps; SPF, DKIM and DMARC pass on the sending domains.
4. A sales approver approves an outbound policy with templates, a footer (postal address and opt-out line), caps and `merge_fields`.
5. An Outlevo operator reviews the campaign's messages.
6. For the organization's first campaign, a person approves the exact messages.
7. An owner, admin or sales approver starts the campaign in the portal.
8. Outlevo has enabled outbound sending (and contact discovery, if used) for your country after legal review.

Until a campaign has sent, keep your existing pipeline and turn it off only after Outlevo's first approved campaign is live.
