Events and gated assets
Register contacts for webinars and events, record attendance from a webhook, and track downloads of lead magnets through signed links.
Events and assets are two common lead magnets. Contacts register for an event through a public endpoint, and a server-to-server webhook (from Zoom, Google Meet, or Zapier) marks who attended. Gated assets, such as reports and guides, are downloaded through a link that can identify the contact who received it. Both score the contact and can enroll them in workflows.
Paths below use the default route prefixes. See public routes to change them.
Events
Odden\Marketing\Models\MarketingEvent:
use Odden\Marketing\Models\MarketingEvent;
$event = MarketingEvent::create([
'title' => 'Pipeline Masterclass',
'event_type' => 'webinar',
'starts_at' => now()->addWeek(),
'virtual_meeting_url' => 'https://zoom.us/j/123',
'capacity' => 500,
]);
$event->slug; // "pipeline-masterclass"| Attribute | Notes |
|---|---|
title |
Required. |
slug |
Unique. Generated from title when empty. |
description |
Free text. |
event_type |
Free string, default webinar. Suggested: webinar, in_person, workshop, round_table. |
status |
Free string, default scheduled. Suggested: draft, scheduled, live, completed, cancelled. |
starts_at, ends_at, timezone |
timezone defaults to UTC. |
virtual_meeting_url, location |
Returned to registrants. |
capacity |
Optional maximum number of registrations. |
is_published |
Defaults to true. |
registrations_count, attendees_count |
Maintained by the actions below. |
Helpers: attendanceRate(): float (attendees / registrations × 100, one decimal) and isFull(): bool (capacity set and reached). Relations: registrations and contacts (with status, registered_at, attended_at on the pivot).
The registration endpoint doesn't check status, is_published, or capacity. If you need to close registration, check isFull() or the status in your own front end, or wrap the endpoint.
Registrations are Odden\Marketing\Models\MarketingEventRegistration rows: event_id, contact_id (unique together), status (registered, attended, no_show, cancelled by convention), registered_at, attended_at, and utm_source, utm_medium, utm_campaign.
Registration endpoint
| Method | URI | Route name | Auth |
|---|---|---|---|
POST |
/api/marketing/events/{slug}/register |
odden.marketing.events.register |
Public. CSRF exempt, rate limited by odden-public. |
curl -X POST https://your-app.test/api/marketing/events/pipeline-masterclass/register \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "first_name": "Ana", "utm_source": "newsletter"}'| Field | Rules |
|---|---|
email |
Required, valid email, max 255. |
first_name, last_name |
Optional, max 255. |
utm_source, utm_medium, utm_campaign |
Optional, max 255. |
{
"success": true,
"message": "Successfully registered for Pipeline Masterclass",
"event": {
"title": "Pipeline Masterclass",
"starts_at": "2026-10-09T16:45:00+00:00",
"virtual_meeting_url": "https://zoom.us/j/123"
},
"registration": {
"id": 1,
"status": "registered"
}
}The contact is matched by the email exactly as sent (it isn't lowercased) and created if missing, with first_name defaulting to Attendee. A placeholder Attendee name is replaced when a later registration includes a first name. An unknown slug returns 404.
Odden\Marketing\Actions\RegisterContactForEventAction then creates or updates the registration. You can call it directly:
use Odden\Marketing\Actions\RegisterContactForEventAction;
$registration = app(RegisterContactForEventAction::class)->execute(
event: $event,
contact: $contact,
utm: ['utm_source' => 'partner'],
);On the first registration of a contact for an event it increments registrations_count, logs a Registered for Event: {title} task, and adds 10 points (property_match scoring event). Registering again resets the registration to registered with a new registered_at and keeps earlier UTM values unless new ones are given, but doesn't score again.
Attendance webhook
| Method | URI | Route name | Auth |
|---|---|---|---|
POST |
/api/marketing/events/{slug}/attendance-webhook |
odden.marketing.events.attendance-webhook |
ODDEN_MARKETING_API_TOKEN, rate limited by odden-api. CSRF exempt. |
Send the token as described in API tokens.
curl -X POST https://your-app.test/api/marketing/events/pipeline-masterclass/attendance-webhook \
-H "Authorization: Bearer $ODDEN_MARKETING_API_TOKEN" \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email": "ana@example.com", "status": "attended"}'{
"success": true,
"status": "attended",
"attended_at": "2026-10-09T17:02:11+00:00"
}status defaults to attended and isn't validated; any string is stored. The contact is looked up by the exact email. Errors:
| Status | Body |
|---|---|
422 |
{"error": "Email required"} |
404 |
{"error": "Contact not found"} or {"error": "Registration not found"} (also a plain 404 for an unknown slug) |
Odden\Marketing\Actions\UpdateAttendanceStatusAction::execute(MarketingEventRegistration $registration, string $status) does the work and can be called directly. It recounts the event's attendees_count from registrations with status attended. When a registration becomes attended for the first time it also:
- sets
attended_at; - logs an
Attended Event: {title}task; - adds 20 points (
property_match); - enrolls the contact in active
event_attendedworkflows whosetrigger_config.event_idis this event or unset.
Gated assets
Odden\Marketing\Models\MarketingAsset represents a downloadable file or link:
use Odden\Marketing\Models\MarketingAsset;
$asset = MarketingAsset::create([
'name' => 'State of RevOps 2026',
'asset_type' => 'report',
'external_url' => 'https://cdn.example.com/state-of-revops-2026.pdf',
'lead_score_points' => 25,
]);| Attribute | Notes |
|---|---|
name |
Required. |
slug |
Unique. Generated from name when empty. |
asset_type |
Free string, default whitepaper. Suggested: whitepaper, case_study, guide, template, spreadsheet, report. |
external_url |
When set, downloads redirect here. |
file_path |
Otherwise, a path relative to storage/app that's streamed as a download. |
description, file_size_kb |
Informational. |
lead_score_points |
Points added per identified download. Default 15. |
is_gated, is_active |
Default true. Stored only; the download route doesn't check them. |
downloads_count, unique_leads_count |
Maintained by the download tracker. |
Relations: downloads (MarketingAssetDownload rows) and downloadingContacts.
Signed download links
| Method | URI | Route name | Auth |
|---|---|---|---|
GET |
/marketing/assets/{slug}/download |
odden.marketing.assets.download |
Public |
getDownloadUrl(?Contact $contact = null): string builds the link. With a contact, it adds contact_id and a signature, an HMAC-SHA256 of the asset id and contact id keyed with app.key:
$url = $asset->getDownloadUrl($contact);
// https://your-app.test/marketing/assets/state-of-revops-2026/download?contact_id=17&signature=9b1e...Put this link in the email that delivers the asset (for example after a form submission) so the download is attributed to the contact. Links don't expire. A link without a valid signature still downloads the asset, but anonymously; a bare contact_id never identifies anyone. resolveSignedContact(mixed $contactId, mixed $signature): ?Contact checks a signature yourself.
The route never asks for a form: the "gate" is whatever you put in front of the link. If the asset has neither external_url nor an existing file_path, the route redirects back with a success flash message and nothing is downloaded.
Download tracking
Every request to the download route runs Odden\Marketing\Actions\TrackAssetDownloadAction, which:
- stores a
MarketingAssetDownload(contact_id,ip_address,user_agent, a randomdownload_token,downloaded_at); - increments
downloads_count, andunique_leads_counton a contact's first download.
For an identified contact it also:
- logs a
Downloaded Asset: {name}task; - adds
lead_score_points(property_match, skipped when 0), on every download; - enrolls the contact in active
asset_downloadedworkflows whosetrigger_config.asset_idis this asset or unset.
To record a download from your own code:
use Odden\Marketing\Actions\TrackAssetDownloadAction;
$download = app(TrackAssetDownloadAction::class)->execute(asset: $asset, contact: $contact);The full signature is execute(MarketingAsset $asset, ?Contact $contact = null, ?string $ipAddress = null, ?string $userAgent = null): MarketingAssetDownload.