mailtanimailtani

API Integration Guide

This guide explains how to connect your application to Correios via the API. It covers authentication, syncing contacts, firing events, and the patterns that make integrations reliable and maintainable.

Authentication

Every API request requires two headers:

Authorization: Bearer mk_your_api_key_here
X-Project-Id: your_project_id
Content-Type: application/json

Get your API key from Settings > API Keys. Your project ID is shown on the Projects page.

Core Operations

Sync a Contact

Call this whenever user data changes in your app. It creates the contact if they don't exist, or updates them if they do.

curl -X POST https://mailtani.com/api/v1/contacts \
  -H "Authorization: Bearer mk_your_api_key_here" \
  -H "X-Project-Id: your_project_id" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "firstName": "Jane",
    "lastName": "Doe",
    "custom_values": {
      "plan": "free",
      "trialEndsAt": "2026-03-23",
      "isOnTrial": "true",
      "hasUploadedVideo": "false",
      "dashboardUrl": "https://yourapp.com/dashboard",
      "plansUrl": "https://yourapp.com/plans"
    }
  }'

Fire an Event

Call this when something happens that should trigger a flow.

curl -X POST https://mailtani.com/api/v1/events \
  -H "Authorization: Bearer mk_your_api_key_here" \
  -H "X-Project-Id: your_project_id" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "event_name": "user.registered",
    "properties": {
      "firstName": "Jane",
      "dashboardUrl": "https://yourapp.com/dashboard"
    }
  }'

Properties passed in the event payload are stored on the contact's custom fields (if those fields exist). So {{custom.dashboardUrl}} in a flow email will resolve correctly even if the contact was just created.

Forget a Contact When an Account Is Deleted

When a user deletes their account, or asks you to delete it, call POST /contacts/forget. It stops every flow the contact is enrolled in and archives them, so later events or syncs can never enroll or email them again.

curl -X POST https://mailtani.com/api/v1/contacts/forget \
  -H "Authorization: Bearer mk_your_api_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "[email protected]",
    "reason": "account_deleted",
    "send_farewell": true
  }'

The deletion confirmation email lives in Mailtani, not in your app. Build a flow with the Event Received trigger set to contact.forgotten. With send_farewell: true, Mailtani fires that event after archiving, and that flow is the only kind allowed to email the archived contact.

Call it after your own deletion succeeds, so a failed deletion doesn't leave a live user who has been silenced. See Forget a Contact for the full reference.

The vitelnk Integration Pattern

vitelnk uses a three-layer integration pattern that handles all use cases cleanly:

Layer 1: Sync on Every Change

A MailtaniService::createOrUpdateContact() call is made whenever the user's data changes — on registration, subscription, upload, settings change. This keeps custom field values fresh in Correios.

// In a controller or event listener
app(MailtaniService::class)->createOrUpdateContact($user);

The contact sync includes all relevant fields: plan, trial status, video count, total views, dashboard URL, etc.

Layer 2: Fire Events from App Logic

Events are fired from the specific code paths where the action happens:

// In UserController (registration)
app(MailtaniService::class)->fireEvent($user->email, 'user.registered', [
    'firstName' => $user->firstName,
    'dashboardUrl' => url('/dashboard'),
]);

// In VideoController (after upload)
app(MailtaniService::class)->fireEvent($user->email, 'video.uploaded', [
    'videoTitle' => $video->title,
    'videoCount' => $user->videos()->count(),
]);

// In PolarEventListener (subscription)
app(MailtaniService::class)->fireEvent($user->email, 'user.subscribed', [
    'plan' => $subscription->plan,
    'billingCycle' => $subscription->billingCycle,
]);

Layer 3: Cron Jobs for Time-Based Events

Time-based events can't be fired from app code — they need to run on a schedule. The pattern is:

  1. Write an Artisan command that queries your DB for contacts that qualify
  2. Fire the event for each qualifying contact
  3. Record in a sent_marketing_emails table to prevent double-sends
  4. Schedule the command to run on the appropriate interval
// app/Console/Commands/Mailtani/FireTrialEvents.php

public function handle(): void
{
    $this->fireTrialExpiringEvents();
    $this->fireTrialExpiredEvents();
}

private function fireTrialExpiringEvents(): void
{
    $users = User::whereNotNull('trialEndsAt')
        ->whereBetween('trialEndsAt', [now()->addHours(47), now()->addHours(49)])
        ->whereDoesntHave('subscriptions', fn($q) => $q->where('status', 'active'))
        ->whereDoesntHave('sentMarketingEmails', fn($q) => $q->where('type', 'trial_expiring'))
        ->get();

    foreach ($users as $user) {
        if ($this->option('dry-run')) {
            $this->line("Would fire trial.expiring for {$user->email}");
            continue;
        }

        $this->mailtaniService->fireEvent($user->email, 'trial.expiring', [
            'trialEndsAt' => $user->trialEndsAt->toIso8601String(),
            'hoursRemaining' => $user->trialEndsAt->diffInHours(now()),
            'upgradeUrl' => url('/plans'),
        ]);

        SentMarketingEmail::create(['user_id' => $user->id, 'type' => 'trial_expiring']);
    }
}

Schedule in routes/console.php or app/Console/Kernel.php:

Schedule::command('mailtani:fire-trial-events')->hourly();
Schedule::command('mailtani:fire-nudge-events')->dailyAt('09:00');
Schedule::command('mailtani:fire-reengagement-events')->dailyAt('10:00');
Schedule::command('mailtani:sync-contacts')->everySixHours();

Custom Fields

Custom fields in Correios store per-contact data. Before syncing values, the fields must exist in your project.

Create a custom field (via dashboard or API):

curl -X POST https://mailtani.com/api/v1/custom-fields \
  -H "Authorization: Bearer mk_your_api_key_here" \
  -H "X-Project-Id: your_project_id" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "trialEndsAt",
    "label": "Trial Ends At",
    "type": "date"
  }'

This endpoint is idempotent — calling it again with the same name won't create a duplicate. Safe to call on every deploy.

Field types: text, number, boolean, date

Sync values via contact upsert:

{
  "email": "[email protected]",
  "custom_values": {
    "trialEndsAt": "2026-03-23",
    "isOnTrial": "true",
    "videoCount": "3"
  }
}

All values are stored as strings internally. Date fields should be sent as ISO 8601 (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SSZ). Boolean fields as "true" or "false". Number fields as string representation of the number.

Deduplication

Correios prevents a contact from being in the same flow twice simultaneously, but it doesn't prevent you from firing the same event repeatedly. If your cron fires trial.expiring every hour and a user qualifies for 2 hours, they'll get enrolled twice.

Solution: maintain a sent_marketing_emails table in your app.

CREATE TABLE sent_marketing_emails (
    id BIGINT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    type VARCHAR(100) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    UNIQUE (user_id, type)
);

Before firing an event:

if (SentMarketingEmail::where(['user_id' => $user->id, 'type' => 'trial_expiring'])->exists()) {
    continue; // Already sent
}

After firing:

SentMarketingEmail::create(['user_id' => $user->id, 'type' => 'trial_expiring']);

This prevents duplicate sends regardless of how many times the cron runs.

Event Naming Conventions

Use noun.verb format, all lowercase, dot-separated:

user.registered
user.subscribed
user.inactive
trial.expiring
trial.expired
video.uploaded
video.first_view_received
subscription.cancelled
credits.purchased
milestone.100_views

Properties as state: When you want to branch in a flow based on a contact's state, pass it as a property rather than creating separate events:

{
  "event_name": "trial.no_upload",
  "properties": {
    "nudgeDay": 7
  }
}

Then in Correios: trial.no_upload trigger → Audience Filter nudgeDay = 7 → send the right email.

This is better than creating trial.no_upload_day_7 and trial.no_upload_day_12 as separate events.

PHP / Laravel Code Examples

MailtaniService wrapper

class MailtaniService
{
    private Http $http;
    private string $apiKey;
    private string $projectId;
    private bool $enabled;

    public function __construct()
    {
        $this->apiKey = config('services.mailtani.api_key');
        $this->projectId = config('services.mailtani.project_id');
        $this->enabled = config('services.mailtani.enabled');
    }

    public function createOrUpdateContact(User $user): void
    {
        if (!$this->enabled) return;

        Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'X-Project-Id' => $this->projectId,
        ])->post('https://mailtani.com/api/v1/contacts', [
            'email' => $user->email,
            'firstName' => $user->firstName,
            'custom_values' => $this->getContactProperties($user),
        ]);
    }

    public function fireEvent(string $email, string $eventName, array $properties = []): void
    {
        if (!$this->enabled) return;

        Http::withHeaders([
            'Authorization' => "Bearer {$this->apiKey}",
            'X-Project-Id' => $this->projectId,
        ])->post('https://mailtani.com/api/v1/events', [
            'email' => $email,
            'event_name' => $eventName,
            'properties' => $properties,
        ]);
    }

    private function getContactProperties(User $user): array
    {
        return [
            'plan' => $user->plan ?? 'free',
            'isOnTrial' => $user->isOnTrial() ? 'true' : 'false',
            'trialEndsAt' => $user->trialEndsAt?->toIso8601String(),
            'hasUploadedVideo' => $user->videos()->exists() ? 'true' : 'false',
            'videoCount' => (string) $user->videos()->count(),
            'dashboardUrl' => url('/dashboard'),
            'plansUrl' => url('/plans'),
        ];
    }
}

Enabling for local testing

In config/services.php:

'mailtani' => [
    'enabled' => env('APP_ENV') === 'production' || env('MAILTANI_TESTING', false),
],

Set MAILTANI_TESTING=true in your local .env to enable Correios integration without changing APP_ENV.