Skip to main content
mailyoto

Deliver email to a webhook

Send each email to an alias as a signed JSON request to your own URL, and check on your server that the request came from Mailyoto.

Pro or Max plan Updated 31 October 2026

When you finish, every email to orders@lunavera.com reaches your server as a JSON POST that you can check and act on.

Before you start

  • A Pro or Max plan. Webhook destinations and the signing secret are paid features.
  • A public URL on your server that accepts a JSON POST. Mailyoto refuses localhost and 127.0.0.1.
  1. Put your URL in the destination#

    Open your domain’s Forwarding tab, then create an alias with Create alias or open an existing one with Edit alias.

    In Destinations, enter the URL and press Enter. Add any email addresses that should also get the message the same way. Mailyoto treats an entry that starts with https:// or http:// as a webhook, and anything else as an email address.

    Address
    orders
    Destinations
    https://shop.lunavera.com/hooks/orders, amina.kone@gmail.com
    The Edit alias form for orders@lunavera.com with 2 destinations: https://shop.lunavera.com/hooks/orders, then amina.kone@gmail.com.

    A rule can forward to the URL too: choose Forward under Then and enter the URL as a destination.

  2. Copy the signing secret#

    Open the domain’s Settings tab and find Webhook signing. Select Reveal and copy the Signing secret into your server’s configuration.

    The Webhook signing card on the Settings tab of lunavera.com, with the signing secret revealed and the Rotate secret button below it.

    Each domain has its own secret. Keep it on the server only.

  3. Send a test#

    On the Forwarding tab, use Send test (the paper plane) on the alias.

    Open Logs. A delivery your endpoint accepted reads 250 Endpoint accepted it with HTTP 200., with the status your endpoint sent.

Important

Use an https:// URL. Mailyoto also accepts http://, but then the email crosses the internet unencrypted.

What your endpoint receives#

Each request is a POST with a JSON body that holds one email. It carries these headers:

HeaderWhat it holds
Content-Typeapplication/json
User-Agentmailyoto-webhook/1.0
Mailyoto-Timestamp, Mailyoto-SignatureThe signature of the request. Check that a request came from Mailyoto shows how to verify it.

The body starts with what Mailyoto knows about the delivery:

FieldWhat it holds
idThe log entry of this delivery. It stays the same on every retry.
attempt1 on the first try, then 2, 3 and so on.
received_atWhen Mailyoto received the email, in UTC (ISO 8601).
envelopeWhat the sending server told Mailyoto: mail_from, rcpt_to, helo, client_ip and client_ptr.
authenticationOne entry per check Mailyoto ran when the email arrived. Each has method (dkim, spf, dmarc or arc), result (as in an Authentication-Results header, such as pass, fail, softfail or none) and domain, the domain the check looked at. null for email sent through Mailyoto, which isn’t checked.
messageThe email itself.
raw_messageThe whole original email, base64-encoded, when the URL has include_raw=true. Otherwise null.
raw_message_urlA link to the original email, https://api.mailyoto.com/v1/domains/<domain>/logs/<log id>/message, when Keep the full content of failed email is on for the domain. Open it with an API key. Otherwise null.

message always has every field below. Your code never has to check whether a field exists.

FieldWhat it holds
message_idThe Message-ID header without its angle brackets, or null.
subjectThe subject, or null.
sent_atThe Date header in UTC (ISO 8601). null when the email has no date Mailyoto can read.
from, senderAn object with name and address, or null.
reply_to, to, cc, bccLists of name and address objects. Empty when the header is missing.
text, htmlThe 2 versions of the body. Either can be null.
attachmentsOne entry per file: filename, content_type, size in bytes and content in base64.
inline_imagesThe images the HTML shows through a cid: link. Each has the fields of an attachment plus content_id.
headersEvery header of the email, under its lower-case name. Each value is a list, in the order the headers appear.

A shortened example:

{
  "id": "log_01m5pf2v9y4b8d1x6s3w0c7nqa",
  "attempt": 1,
  "received_at": "2026-10-23T09:14:05Z",
  "envelope": {
    "mail_from": "diego@harborpine.net",
    "rcpt_to": "orders@lunavera.com",
    "helo": "mail.harborpine.net",
    "client_ip": "203.0.113.7",
    "client_ptr": "mail.harborpine.net"
  },
  "authentication": [
    { "method": "dkim", "result": "pass", "domain": "harborpine.net" },
    { "method": "spf", "result": "pass", "domain": "harborpine.net" },
    { "method": "dmarc", "result": "pass", "domain": "harborpine.net" },
    { "method": "arc", "result": "none", "domain": null }
  ],
  "message": {
    "message_id": "CAF7k2xq81@mail.harborpine.net",
    "subject": "Order 1042",
    "sent_at": "2026-10-23T09:14:02Z",
    "from": { "name": "Diego Ramos", "address": "diego@harborpine.net" },
    "sender": null,
    "reply_to": [],
    "to": [{ "name": null, "address": "orders@lunavera.com" }],
    "cc": [],
    "bcc": [],
    "text": "Two boxes of the blue tiles, please.",
    "html": "<p>Two boxes of the blue tiles, please.</p>",
    "attachments": [
      { "filename": "order-1042.pdf", "content_type": "application/pdf", "size": 48213, "content": "JVBERi0xLjcK..." }
    ],
    "inline_images": [],
    "headers": { "subject": ["Order 1042"], "received": ["from mail.harborpine.net ..."] }
  },
  "raw_message": null,
  "raw_message_url": null
}

Check that a request came from Mailyoto#

Anyone who learns your URL can post to it. Mailyoto signs every request. Your endpoint can then refuse anything Mailyoto didn’t send. Each request carries 2 headers:

HeaderWhat it holds
Mailyoto-TimestampWhen Mailyoto sent the request, in Unix seconds.
Mailyoto-Signaturesha256= and a hex HMAC-SHA256 of the timestamp, a dot and the raw body, keyed with your signing secret.

To check a request:

  1. Keep the body as the exact bytes you received. Parsing the JSON and writing it out again changes the bytes.
  2. Refuse the request if the timestamp is far from your server’s clock. The examples below allow 5 minutes, which stops someone replaying an old request.
  3. Compute the HMAC-SHA256 of the timestamp, a dot and the raw body, with your secret as the key.
  4. Compare your result with each sha256= value in Mailyoto-Signature, in constant time. Accept the request if one matches.

Python

import hashlib
import hmac
import time

MAX_AGE_SECONDS = 5 * 60


def is_from_mailyoto(secret, timestamp, signature_header, raw_body):
    """raw_body is the request body as bytes, exactly as received."""
    if abs(time.time() - int(timestamp)) > MAX_AGE_SECONDS:
        return False
    expected = hmac.new(
        secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
    ).hexdigest()
    return any(
        hmac.compare_digest(expected, entry.strip().removeprefix("sha256="))
        for entry in signature_header.split(",")
    )

In Flask, the raw body is request.get_data(), and the headers are request.headers["Mailyoto-Timestamp"] and request.headers["Mailyoto-Signature"].

Node.js

const crypto = require("node:crypto");

const MAX_AGE_SECONDS = 5 * 60;

// rawBody is the request body as a Buffer, exactly as received.
function isFromMailyoto(secret, timestamp, signatureHeader, rawBody) {
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > MAX_AGE_SECONDS) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest();
  return signatureHeader.split(",").some((entry) => {
    const given = Buffer.from(entry.trim().replace(/^sha256=/, ""), "hex");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

In Express, read the body with express.raw({ type: "application/json" }) so req.body is the raw Buffer. Parse the JSON yourself once the check passes.

Change the secret#

Select Rotate secret under Webhook signing. For the next 24 hours, each request carries 2 signatures, one from the new secret and one from the old. Your endpoint keeps accepting requests while you deploy the new secret. The API can do the same: see the webhook signing secret on the API reference.

Change what the request carries#

Add these options to the URL as query parameters. They apply to that one destination.

ParameterEffect
include_attachments=falseSends each attachment’s filename, content_type and size, with content set to null.
include_raw=trueFills raw_message with the whole original email, base64-encoded.
skip_spam_check=trueSkips the spam check, but only when every recipient of the email is a webhook with this option.
https://shop.lunavera.com/hooks/orders?include_attachments=false&include_raw=true

Mailyoto refuses spam while the sending server is still connected. A refused email never reaches your endpoint. Use skip_spam_check=true when your own system filters spam.

Responses, timeouts and retries#

Your endpointWhat Mailyoto does
Responds 2xx within 20 secondsLogs the delivery with code 250.
Responds 400 or 413Stops trying, because the same request would fail again.
Responds 410Stops trying, and pauses the webhook.
Responds 429 or 503 with a Retry-After headerWaits as long as the header asks, or for the next gap if that is longer.
Responds with any other status, takes longer than 20 seconds, or can’t be reachedTries again after the next gap.

The gaps between tries are 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour and 3 hours, then 6 hours each. Each gap is up to 20% shorter or longer, so emails that failed together don’t all return at once. Mailyoto stops trying 3 days after the email arrived, with a last try at that moment.

Every try is in your email logs. A try that will be repeated has code 451, and its entry shows which try it was and when the next one runs. A delivery that stopped has code 554.

When your endpoint stops working#

Mailyoto pauses every alias and routing rule that delivers to your URL when:

  • the endpoint responds 410, or
  • an email still fails a day after it arrived, and no delivery to the URL succeeded that day.

You get one email that names the paused aliases and rules, and the last response. While an alias is paused, it holds new email for retry, unless you chose Return to sender or Discard quietly for it. A paused rule holds the email it matches for retry. Emails that arrived before the pause keep their tries.

To resume the webhook:

  1. Make the endpoint respond with a 2xx status.
  2. Select Resume on each paused alias and rule in Forwarding.
  3. On the domain’s Usage and logs tab, resend any email that failed.
Saving the alias says webhooks need a paid plan

Webhook destinations work on Pro and Max. Change your plan under Billing, then save the alias again.

Saving the alias says the URL can't be local

Mailyoto can’t reach localhost or 127.0.0.1 on your computer. Use a public URL, or a tunnel that gives your local server one.

The signature never matches

Check the raw body, exactly as it arrived, before any JSON parsing. Use the secret of the domain the alias belongs to. Check that your server’s clock is right.

An alias or rule with a webhook shows Paused · holding email

Your endpoint responded 410, or failed for a day. The email we sent and your email logs show its last response. Fix the endpoint, then select Resume on the alias or rule.

The same email arrives more than once

Your endpoint took longer than 20 seconds or returned an error. Mailyoto then sent it again. Respond with 2xx first and do slow work afterwards.

Use id to skip an email you already handled. It stays the same on every retry, and attempt counts the tries.