Skip to content

Duva API (v1)

Base URL: https://api.duva.ca

API reference. Available endpoints: POST /v1/{domain}/messages, GET /v1/{domain}/messages/{id}, GET /v1/{domain}/events, suppressions, webhooks and statistics.

The OpenAPI 3.1 specification of the API (every route, schema, error and the signed webhook event) is published on this site: /openapi.json. Use it to generate a client in the language of your choice.

Sending status. The message is accepted, validated and queued (queued). Duva then hands it to the sending server, one copy per address (each copy is sent to a single address; the To and Cc headers show the to and cc recipients of the message, see "Recipients"): sent means "handed to the sending server". Events from the sending server then move each recipient to delivered (accepted by the recipient's server) or bounced (permanent refusal, immediate or received afterwards). Open and click tracking applies to the messages that ask for it (see "Open and click tracking").

Verification. An unusual volume for the account (a recent account sending a lot, or a rate far above its usual) may cause a message to be held for verification before it is sent: the response is the same (202, queued) and the message stays queued until a decision is made. A message that has not been released within 24 hours is abandoned (failed). To avoid this delay, ramp your volume up gradually, or write to us before a large send.

Authentication

One API key per domain, in the Authorization: Bearer dv_... header. The key only opens the domain it was created for (the "API keys" screen of the dashboard), and the {domain} in the path must be that domain.

  • No key presented (or a scheme other than Bearer): 401.
  • Key presented but unknown, wrong, revoked, expired, or belonging to another domain; unknown domain; closed account: 404, always the same response, without saying which of these causes applies.

POST /v1/{domain}/messages

Accepts a message. The response is always asynchronous: it never confirms a delivery.

curl

curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
  -H "Authorization: Bearer dv_xxxxxxxxxx_..." \
  -H "Idempotency-Key: quote-4821" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Soumissio <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your quote",
    "html": "<p>Hello</p>",
    "text": "Hello",
    "tags": ["quote"]
  }'

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const message = await duva.messages.send({
  from: "Soumissio <[email protected]>",
  to: ["[email protected]"],
  subject: "Your quote",
  html: "<p>Hello</p>",
  text: "Hello",
  tags: ["quote"],
  idempotencyKey: "quote-4821",
});
console.log(message.id, message.status);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
message = duva.messages.send(
    from_="Soumissio <[email protected]>",
    to=["[email protected]"],
    subject="Your quote",
    html="<p>Hello</p>",
    text="Hello",
    tags=["quote"],
    idempotency_key="quote-4821",
)
print(message.id, message.status)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$message = $duva->messages->send([
    "from" => "Soumissio <[email protected]>",
    "to" => ["[email protected]"],
    "subject" => "Your quote",
    "html" => "<p>Hello</p>",
    "text" => "Hello",
    "tags" => ["quote"],
    "idempotency_key" => "quote-4821",
]);
echo $message->id . " " . $message->status . "\n";

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
	if err != nil {
		panic(err)
	}

	result, err := client.Messages.Send(context.Background(), duva.SendMessageParams{
		From:           "Soumissio <[email protected]>",
		To:             []string{"[email protected]"},
		Subject:        "Your quote",
		HTML:           "<p>Hello</p>",
		Text:           "Hello",
		Tags:           []string{"quote"},
		IdempotencyKey: "quote-4821",
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(result.ID, result.Status)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
result = duva.messages.deliver(
  from: "Soumissio <[email protected]>",
  to: ["[email protected]"],
  subject: "Your quote",
  html: "<p>Hello</p>",
  text: "Hello",
  tags: ["quote"],
  idempotency_key: "quote-4821"
)
puts "#{result.id} #{result.status}"

Response 202 Accepted

{ "id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", "status": "queued" }

status is queued, except when all recipients are on the suppression list: failed. Header Location: /v1/{domain}/messages/{id}.

Request body

Unknown fields are rejected (422), so that a typo such as replyto is not silently ignored.

Field Type Rules
from text Required. address or Name <address>. The domain must be the one in the path (subdomains excluded).
to list of texts Required. address or Name <address>. Deduplicated case-insensitively. to, cc and bcc count together against the plan's maximum (5 in sandbox). One copy per address; every copy shows all the to and all the cc. See "Recipients".
cc list of texts Optional, same format as to. Visible to every recipient. An address already in to is ignored.
bcc list of texts Optional, same format as to. Never visible to the other recipients. An address already in to or cc is ignored.
subject text Required, a single line, 998 characters at most, no control characters.
html, text text At least one of the two. 2 MB in total. No NUL character.
tags list of texts 10 at most, 64 characters each, no leading/trailing whitespace. Deduplicated.
tracking object {"opens": bool, "clicks": bool}, off by default. Requires html (see below); asking for tracking without html, or on a domain that has not enabled it: 422 (see below).
reply_to text address or Name <address>.
headers object 20 headers at most, single-line values. Allowlist: List-Unsubscribe, List-Unsubscribe-Post, List-Id, In-Reply-To, References, Auto-Submitted, Precedence, Importance, Feedback-ID, and any X-* except the prefixes reserved by the platform (X-Duva* and a few others, rejected with 422). From, To, Cc, Bcc, Return-Path, Message-ID, DKIM-Signature, etc. are rejected. Checked values: List-Unsubscribe (<https://…> or <mailto:…> links, 3 at most), List-Unsubscribe-Post (exactly List-Unsubscribe=One-Click, with an https link), Precedence (bulk, list, junk), Auto-Submitted (no, auto-generated, auto-replied, auto-notified), Importance (high, normal, low).
metadata object Text to text, never sent to the recipient: returned by GET /messages/{id}, in every event and in the webhooks of this message. 10 keys at most (letters, digits, _, ., -, 40 characters), values of 500 characters on a single line.
attachments list of objects 10 at most, 5 MB decoded in total. See “Attachments”. Production accounts only.

Addresses whose local part is not ASCII are not supported. The domain is normalized (lowercase, punycode).

What Duva adds to the message. If you send only html, Duva attaches a plain-text version derived from that HTML (paragraphs, lists and links written as “label (address)”): an HTML-only message is more often classified as spam. Send your own text to control it; an HTML with no text at all stays HTML-only. Duva also adds a Feedback-ID header (<domain>:transactional:duva), which Gmail uses to break down its reputation reports, unless you supply one in headers. Every message is DKIM-signed with your domain; Duva may add a second DKIM signature on behalf of a platform domain, which lets it receive mailbox providers' complaints and follow its service's reputation. Your DMARC does not depend on it: it always aligns on your domain's signature.

The whole request cannot exceed 4 MB (413), except sending a message: 9 MB, for its attachments.

Recipients: to, cc and bcc

The three fields accept an address ([email protected]) or a name followed by the address (Jean Tremblay <[email protected]>). The name cannot contain @, a quote, a backslash or a control character (200 characters at most). to is required; cc and bcc are optional.

  • One copy per address. Duva sends a separate copy to each address, whichever field it is in: each one receives ONE message and has its own status (GET /messages/{id}).
  • to and cc see each other. Every copy carries the same To (all the to) and Cc (all the cc) headers, including the copy of a bcc.
  • A bcc stays hidden. Its address is on no other recipient's copy. Its own copy carries a Bcc header that contains only its address, so it understands why it receives the message.
  • One ceiling, for the total. to, cc and bcc count together against the plan's maximum number of recipients and the sending quota. An address on the suppression list is not sent and does not count.
  • An address exists once. If it appears in several fields (case does not matter), the first occurrence wins in the order to, cc, bcc, with its name. Exceeding the ceiling gives 422, on the to field if only to is given, on recipients otherwise.
  • The cc and bcc fields replace the Cc and Bcc entries of headers, which are still refused.

curl

curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
  -H "Authorization: Bearer dv_xxxxxxxxxx_..." \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Soumissio <[email protected]>",
    "to": ["Jean Tremblay <[email protected]>"],
    "cc": ["[email protected]"],
    "bcc": ["[email protected]"],
    "subject": "Votre soumission",
    "text": "Bonjour"
  }'

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const message = await duva.messages.send({
  from: "Soumissio <[email protected]>",
  to: ["Jean Tremblay <[email protected]>"],
  cc: ["[email protected]"],
  bcc: ["[email protected]"],
  subject: "Votre soumission",
  text: "Bonjour",
});
console.log(message.id, message.status);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
message = duva.messages.send(
    from_="Soumissio <[email protected]>",
    to=["Jean Tremblay <[email protected]>"],
    cc=["[email protected]"],
    bcc=["[email protected]"],
    subject="Votre soumission",
    text="Bonjour",
)
print(message.id, message.status)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$message = $duva->messages->send([
    "from" => "Soumissio <[email protected]>",
    "to" => ["Jean Tremblay <[email protected]>"],
    "cc" => ["[email protected]"],
    "bcc" => ["[email protected]"],
    "subject" => "Votre soumission",
    "text" => "Bonjour",
]);
echo $message->id . " " . $message->status . "\n";

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
	if err != nil {
		panic(err)
	}

	result, err := client.Messages.Send(context.Background(), duva.SendMessageParams{
		From:    "Soumissio <[email protected]>",
		To:      []string{"Jean Tremblay <[email protected]>"},
		CC:      []string{"[email protected]"},
		BCC:     []string{"[email protected]"},
		Subject: "Votre soumission",
		Text:    "Bonjour",
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(result.ID, result.Status)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
result = duva.messages.deliver(
  from: "Soumissio <[email protected]>",
  to: ["Jean Tremblay <[email protected]>"],
  cc: ["[email protected]"],
  bcc: ["[email protected]"],
  subject: "Votre soumission",
  text: "Bonjour"
)
puts "#{result.id} #{result.status}"

Attachments

Each attachments item: filename (required, 255 characters at most, no path), content (required, base64), content_type (optional, application/octet-stream by default) and content_id (optional: the attachment becomes an “inline” image that the HTML refers to with cid:).

"attachments": [
  { "filename": "Facture-2026-001.pdf", "content": "JVBERi0xLjQK...", "content_type": "application/pdf" }
]

Executable extensions (.exe, .bat, .js, .vbs, .scr, .msi, etc.) are rejected. All the attachments of a message weigh 5 MB decoded at most. An account in the sandbox cannot send any (422). They are never returned by the API and are deleted with the message body.

Idempotency

Optional Idempotency-Key header (1 to 255 printable ASCII characters, no spaces), unique per domain.

  • Same key and same request: 202 with the same id and the header Idempotent-Replayed: true. No quota is consumed again, even if it is exhausted.
  • Same key, different request: 409 idempotency_conflict.
  • Two identical simultaneous requests with the same key create a single message.

Keys do not expire as long as the message exists.

Suppression lists

A recipient on the domain's suppression list is not sent to and consumes no quota. The request is still accepted (202); the per-recipient detail is read with GET /messages/{id} (status suppressed).

Errors

All errors have the same shape, with no internal detail and no received value:

{ "error": { "code": "invalid_request", "message": "The request is invalid.",
             "fields": [{ "field": "to[0]", "message": "the local part or the @ is missing" }] } }

code is the contract; rely on it, and on fields[].field, in your code. message (and fields[].message) is human-readable text in the language of the Accept-Language header: en for English, fr for French. Without the header, or with any other language, the text is in French. The wording may change; the status, code and fields[].field are the same in both languages. Errors carry Vary: Accept-Language.

curl -X POST https://api.duva.ca/v1/soumissio.ca/messages \
  -H "Authorization: Bearer dv_xxxxxxxxxx_..." \
  -H "Accept-Language: en" \
  -H "Content-Type: application/json" \
  -d '{"from": "[email protected]", "to": ["not-an-address"], "subject": "Test", "text": "Hello"}'

The fixed texts that Duva itself writes in a webhook's disabled_reason and last_error follow the same header; the text of your server's response is never returned.

Status code Cause
401 unauthorized No key presented. Header WWW-Authenticate: Bearer.
404 not_found Key unusable for this domain (see Authentication); or, on reads, message not found.
403 domain_not_verified The domain's DNS is not verified yet.
403 sending_not_allowed Suspended account or domain, or an account in a state that forbids sending.
409 idempotency_conflict Idempotency key already used for a different request.
409 limit_reached Limit reached (webhooks per domain).
413 payload_too_large Request larger than 4 MB (9 MB when sending a message).
422 invalid_request Invalid body or parameter; fields names the field (from, to[1], cc[0], bcc[2], recipients, headers.Bcc, body if the JSON is unreadable, limit, cursor...).
429 quota_exceeded Daily or monthly limit reached. Retry-After gives the seconds until the next period (UTC).
429 rate_limited Too many requests with this key in a short time (a rate limit, distinct from the sending quota). Retry-After gives the seconds to wait.
500 internal_error Internal error.

Authentication comes before validation: a wrong key can never be used to probe the validation rules.

A per-key rate limit protects the service: normal usage does not reach it. If you receive a 429, wait the number of seconds in Retry-After before retrying, without a burst of new attempts. A rate_limited 429 does not consume your sending quota.

GET /v1/{domain}/messages/{id}

Status of a message and of each of its recipients.

curl

curl https://api.duva.ca/v1/example.com/messages/msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e \
  -H "Authorization: Bearer $DUVA_API_KEY"

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const message = await duva.messages.get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e");
console.log(message.status);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
message = duva.messages.get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e")
print(message.status)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$message = $duva->messages->get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e");
echo $message->status;

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	message, err := client.Messages.Get(context.Background(), "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e")
	if err != nil {
		panic(err)
	}
	fmt.Println(message.Status)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
message = duva.messages.get("msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e")
puts message.status
{
  "id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
  "status": "sent",
  "from": "[email protected]",
  "subject": "Your quote",
  "tags": ["quote"],
  "metadata": { "order_id": "A-1042" },
  "tracking": { "opens": false, "clicks": false },
  "created_at": "2026-09-19T14:03:21.512Z",
  "recipients": [
    { "email": "[email protected]", "type": "to", "name": "Alex Martin", "status": "delivered", "updated_at": "2026-09-19T14:03:24.101Z" },
    { "email": "[email protected]", "type": "cc", "name": null, "status": "sent", "updated_at": "2026-09-19T14:03:22.870Z" }
  ]
}

type is to, cc or bcc (the request field the address was in); name is the name given with Name <address>, null for a bare address.

Statuses of a recipient: queued (waiting to be sent), sent (handed to the sending server, delivery in progress), delivered, bounced, failed (given up: attempts exhausted, or expired in the sending queue), suppressed (address on the suppression list, never sent). A recipient never moves backwards; the only exception is a bounce received afterwards (delivered then bounced).

Status of the message: that of its least advanced recipient (queued < sent < delivered < bounced < failed); failed only if no recipient was delivered or bounced. A message with a recipient still being delivered therefore stays sent.

A message that does not exist, is malformed, or belongs to another domain or another tenant: the same 404. The message body (html, text) and the headers are never returned. The reason for a failed decided by Duva (attempts exhausted...) is not exposed yet.

GET /v1/{domain}/events

Log of the domain's delivery events, newest first, in pages.

curl

curl "https://api.duva.ca/v1/soumissio.ca/events?type=bounced&limit=20" \
  -H "Authorization: Bearer dv_xxxxxxxxxx_..."

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const page = await duva.events.list({ type: "bounced", limit: 20 });
console.log(page.data.length, page.nextCursor);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
page = duva.events.list(type="bounced", limit=20)
print(len(page.data), page.next_cursor)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$page = $duva->events->list(type: "bounced", limit: 20);
echo count($page->data) . " " . ($page->nextCursor ?? "");

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
	if err != nil {
		panic(err)
	}

	page, err := client.Events.List(context.Background(), duva.ListEventsParams{Type: "bounced", Limit: 20})
	if err != nil {
		panic(err)
	}
	fmt.Println(len(page.Data), page.NextCursor)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
page = duva.events.list(type: "bounced", limit: 20)
puts "#{page.data.length} #{page.next_cursor}"
{
  "data": [
    {
      "id": "evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d",
      "type": "bounced",
      "message_id": "msg_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e",
      "recipient": "[email protected]",
      "occurred_at": "2026-09-19T14:03:25.310Z",
      "detail": { "code": 550, "enhanced_code": "5.1.1", "message": "user unknown",
                  "classification": "InvalidRecipient", "attempts": 0 },
      "metadata": { "order_id": "A-1042" }
    }
  ],
  "next_cursor": "MjAyNi0wOS0xOVQxNDowMzoyNS4zMTAr..."
}
Parameter Purpose
message_id Only the events of this message (msg_...).
type delivered, bounced, deferred, expired, complained, opened or clicked.
recipient Only this address (case does not matter).
since Only events that occurred from this ISO 8601 instant, time zone required (2026-09-19T12:00:00Z); between 1970 and 2200, otherwise 422.
limit 1 to 100 (50 by default).
cursor The next_cursor of the previous page; null = last page. Opaque value.

Types: delivered (the recipient's server accepted the message), bounced (permanent refusal, including a bounce received afterwards), deferred (temporary failure, the sending server will retry: several may precede the final event), expired (the sending server gave up after 24 h), complained (the recipient reported the message; the address is then suppressed). A bounce caused by the address itself (unknown recipient, 5.1.x) adds the address to the domain's suppression list; a reputation or content block (5.7.x) does not. opened and clicked come from Duva's tracking, not from the sending server (see "Open and click tracking").

detail only contains selected fields: code, enhanced_code, message (first line of the remote server's response, 500 characters at most: external text, never to be interpreted), classification, attempts, feedback_type for a complaint, url for a click. Never the content of the message or its headers.

A message_id from another domain or tenant gives an empty list, not an error. Events are delivered at least once by the sending server: Duva deduplicates them, each one appears only once.

GET /v1/{domain}/suppressions

The addresses this domain no longer writes to. They get there through a bounce caused by the address, a complaint, or by hand. An address suppressed in one domain is not suppressed in another.

curl

curl "https://api.duva.ca/v1/example.com/suppressions?reason=bounce&limit=20" \
  -H "Authorization: Bearer $DUVA_API_KEY"

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const page = await duva.suppressions.list({ reason: "bounce", limit: 20 });
console.log(page.data.length, page.nextCursor);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
page = duva.suppressions.list(reason="bounce", limit=20)
print(len(page.data), page.next_cursor)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$page = $duva->suppressions->list(reason: "bounce", limit: 20);
echo count($page->data) . " " . ($page->nextCursor ?? "");

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	page, err := client.Suppressions.List(context.Background(), duva.ListSuppressionsParams{Reason: "bounce", Limit: 20})
	if err != nil {
		panic(err)
	}
	fmt.Println(len(page.Data), page.NextCursor)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
page = duva.suppressions.list(reason: "bounce", limit: 20)
puts "#{page.data.length} #{page.next_cursor}"
{ "data": [ { "email": "[email protected]", "reason": "bounce", "message_id": "msg_9f1c…", "created_at": "2026-09-19T14:03:25.310Z" } ],
  "next_cursor": null }

reason: bounce, complaint, unsubscribe or manual. Parameters: reason (filter), limit (1 to 100, 50 by default), cursor (the next_cursor of the previous page, opaque value). Newest first.

POST /v1/{domain}/suppressions

Adds an address by hand ({"email": "[email protected]"}, reason manual): it will no longer receive anything from this domain. 201 if it is added, 200 if it was already there (with the existing entry).

curl

curl -X POST https://api.duva.ca/v1/example.com/suppressions \
  -H "Authorization: Bearer $DUVA_API_KEY" -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]" }'

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const suppression = await duva.suppressions.add("[email protected]");
console.log(suppression.reason);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
suppression = duva.suppressions.add("[email protected]")
print(suppression.reason)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$suppression = $duva->suppressions->add("[email protected]");
echo $suppression->reason;

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	suppression, created, err := client.Suppressions.Add(context.Background(), "[email protected]")
	if err != nil {
		panic(err)
	}
	fmt.Println(suppression.Reason, created)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
suppression = duva.suppressions.add("[email protected]")
puts suppression.reason

DELETE /v1/{domain}/suppressions/{email}

Removes an address from the list (204); it can receive again. 404 if it is not there (or if it is only on another domain's list: indistinguishable). Removing a bounce or a complaint is your responsibility: writing again to an address that bounces damages the sending reputation. Additions and removals are recorded in the account's audit log.

curl

curl -X DELETE https://api.duva.ca/v1/example.com/suppressions/[email protected] \
  -H "Authorization: Bearer $DUVA_API_KEY"

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
await duva.suppressions.remove("[email protected]");

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
duva.suppressions.remove("[email protected]")

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$duva->suppressions->remove("[email protected]");

Go (duva-go)

package main

import (
	"context"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	if err := client.Suppressions.Remove(context.Background(), "[email protected]"); err != nil {
		panic(err)
	}
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
duva.suppressions.remove("[email protected]")

Webhooks

Duva sends each delivery event (see GET /events) to the URL of your choice, as a signed JSON POST.

POST /v1/{domain}/webhooks

curl

curl -X POST https://api.duva.ca/v1/soumissio.ca/webhooks \
  -H "Authorization: Bearer dv_xxxxxxxxxx_..." -H "Content-Type: application/json" \
  -d '{ "url": "https://soumissio.ca/hooks/duva", "events": ["delivered", "bounced"] }'

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const webhook = await duva.webhooks.create({
  url: "https://soumissio.ca/hooks/duva",
  events: ["delivered", "bounced"],
});
console.log(webhook.id, webhook.secret); // secret: shown only in this response

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
webhook = duva.webhooks.create("https://soumissio.ca/hooks/duva", events=["delivered", "bounced"])
print(webhook.id, webhook.secret)  # secret: shown only in this response

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$webhook = $duva->webhooks->create(url: "https://soumissio.ca/hooks/duva", events: ["delivered", "bounced"]);
echo $webhook->id . " " . $webhook->secret; // secret: shown only in this response

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
	if err != nil {
		panic(err)
	}

	webhook, err := client.Webhooks.Create(context.Background(), duva.CreateWebhookParams{
		URL:    "https://soumissio.ca/hooks/duva",
		Events: []string{"delivered", "bounced"},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(webhook.Id, *webhook.Secret) // Secret: shown only in this response
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
webhook = duva.webhooks.create("https://soumissio.ca/hooks/duva", events: ["delivered", "bounced"])
puts "#{webhook.id} #{webhook.secret}" # secret: shown only in this response

201 with the webhook, including secret (whsec_…): shown only once, keep it to verify signatures. events empty or absent: all types (delivered, bounced, deferred, expired, complained, opened, clicked). The URL must be https:// (no credentials, port 443 or ≥ 1024) and point to the public Internet: private, local or metadata addresses (127.0.0.1, 10.x, 169.254.169.254, ::1...) and internal names are rejected (422). This is checked again on every delivery, after the name is resolved. At most five webhooks per domain (409 limit_reached).

GET /v1/{domain}/webhooks, GET /v1/{domain}/webhooks/{id}, DELETE /v1/{domain}/webhooks/{id}

List, read (without the secret) and delete (204). A webhook from another domain or tenant: 404. status is active or disabled; disabled_reason says why (410 Gone, too many consecutive failures).

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const webhooks = await duva.webhooks.list();
const webhook = await duva.webhooks.get(webhooks[0].id);
await duva.webhooks.delete(webhook.id);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
webhooks = duva.webhooks.list()
webhook = duva.webhooks.get(webhooks[0].id)
duva.webhooks.delete(webhook.id)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$webhooks = $duva->webhooks->list();
$webhook = $duva->webhooks->get($webhooks[0]->id);
$duva->webhooks->delete($webhook->id);

Go (duva-go)

package main

import (
	"context"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
	if err != nil {
		panic(err)
	}

	ctx := context.Background()
	webhooks, err := client.Webhooks.List(ctx)
	if err != nil {
		panic(err)
	}
	webhook, err := client.Webhooks.Get(ctx, webhooks[0].Id)
	if err != nil {
		panic(err)
	}
	if err := client.Webhooks.Delete(ctx, webhook.Id); err != nil {
		panic(err)
	}
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
webhooks = duva.webhooks.list
webhook = duva.webhooks.get(webhooks[0].id)
duva.webhooks.delete(webhook.id)

GET /v1/{domain}/webhooks/{id}/deliveries

The latest deliveries (limit, 50 by default): status (pending, delivered, failed), attempts, last_status_code, last_error (fixed text: HTTP 500, timeout, connection failed...). Never the body of your response.

curl

curl "https://api.duva.ca/v1/example.com/webhooks/wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e/deliveries?limit=20" \
  -H "Authorization: Bearer $DUVA_API_KEY"

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const deliveries = await duva.webhooks.deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", 20);
console.log(deliveries.length);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
deliveries = duva.webhooks.deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", limit=20)
print(len(deliveries))

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$deliveries = $duva->webhooks->deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", limit: 20);
echo count($deliveries);

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	deliveries, err := client.Webhooks.Deliveries(context.Background(), "wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", 20)
	if err != nil {
		panic(err)
	}
	fmt.Println(len(deliveries))
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
deliveries = duva.webhooks.deliveries("wh_9f1c2d3e4a5b46c78d9e0f1a2b3c4d5e", limit: 20)
puts deliveries.length

What you receive

POST /hooks/duva HTTP/1.1
content-type: application/json
webhook-id: evt_0b1c2d3e4f5a46b78c9d0e1f2a3b4c5d
webhook-timestamp: 1789843104
webhook-signature: v1,g0hM9SsE+OeSQNLzdvDx0IHdBBu58Z8ZGkTmDd9wSQ4=

{"data":{"detail":{"code":550,"enhanced_code":"5.1.1"},"message_id":"msg_9f1c…","metadata":{"order_id":"A-1042"},"occurred_at":"2026-09-19T14:03:25.310Z","recipient":"[email protected]"},"domain":"soumissio.ca","id":"evt_0b1c…","type":"bounced"}

Signature in the "Standard Webhooks" format (the standard-webhooks libraries verify it): base64(HMAC-SHA256(secret, "<webhook-id>.<webhook-timestamp>.<raw body>")), where the secret is the base64 part of whsec_<base64>. Verify the signature on the raw body and reject a webhook-timestamp older than 5 minutes.

  • Response: 2xx = received. 410 disables the webhook. Any other response, a 10 s timeout, a connection or certificate error: retry (30 s, then doubling up to 1 h, 10 attempts). Redirects are not followed (a redirect is a failure).
  • At least once: the same event may arrive several times (identical webhook-id, new timestamp and signature); deduplicate on webhook-id. No ordering guaranteed between events.
  • After 100 consecutive failures, the webhook is disabled and its pending deliveries are abandoned.

GET /v1/{domain}/stats

The domain's counters per period, computed from the accepted recipients and the event log.

curl

curl "https://api.duva.ca/v1/soumissio.ca/stats?granularity=day&since=2026-09-01T00:00:00Z" \
  -H "Authorization: Bearer dv_xxxxxxxxxx_..."

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "soumissio.ca" });
const stats = await duva.stats.get({ granularity: "day", since: new Date("2026-09-01T00:00:00Z") });
console.log(stats.totals);

Python (duva-mail)

import os
from datetime import UTC, datetime

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="soumissio.ca")
stats = duva.stats.get(granularity="day", since=datetime(2026, 9, 1, tzinfo=UTC))
print(stats.totals)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "soumissio.ca");
$stats = $duva->stats->get(granularity: "day", since: new DateTimeImmutable("2026-09-01T00:00:00Z"));
print_r($stats->totals);

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"
	"time"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "soumissio.ca")
	if err != nil {
		panic(err)
	}

	since := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
	stats, err := client.Stats.Get(context.Background(), duva.GetStatsParams{Granularity: "day", Since: &since})
	if err != nil {
		panic(err)
	}
	fmt.Println(stats.Totals)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "soumissio.ca")
stats = duva.stats.get(granularity: "day", since: Time.utc(2026, 9, 1))
puts stats.totals
{ "granularity": "day", "since": "2026-09-01T00:00:00.000Z", "until": "2026-09-19T20:00:00.000Z",
  "data": [ { "period": "2026-09-19T00:00:00.000Z", "accepted": 120, "suppressed": 3, "delivered": 110,
              "bounced": 4, "deferred": 9, "expired": 0, "complained": 1, "opened": 40, "clicked": 12 } ],
  "totals": { "accepted": 120, "suppressed": 3, "delivered": 110, "bounced": 4, "deferred": 9, "expired": 0, "complained": 1, "opened": 40, "clicked": 12 } }
Parameter Purpose
granularity day (default) or hour. Periods are in UTC.
since, until ISO 8601 interval (time zone required): since inclusive, until exclusive. Default: the last 30 days (last 24 hours by hour), up to now.

At most 366 days, or 7 days by hour (422 beyond that). Periods with no activity are present, at zero. accepted counts the recipients accepted on the message's acceptance date (excluding suppressed ones, counted in suppressed); the other counters count events on the date they occurred.

Open and click tracking

Off by default (privacy, Quebec's Law 25). Two conditions to use it:

  1. A DNS record: CNAME track.<your domain> pointing to the target shown on your domain's DNS screen ("recommended" record, only needed for tracking). Duva then obtains a TLS certificate for that name.
  2. Activation by Duva (on request, once the CNAME is published). As long as tracking is not enabled, a message that asks for it is rejected (422, field tracking): never broken links or tracking silently missing.

Tracking also requires an html body: links and the pixel only rewrite HTML, never plain text. A tracking message without html is rejected (422, field tracking), for the same reason.

With "tracking": {"opens": true, "clicks": true} (each option separately), at send time:

  • clicks: every http(s)://… link in the HTML part is replaced by a link https://track.<domain>/c/<token> that redirects (302) to the original address. mailto: and tel: links, anchors and relative links are not touched, nor is the text part.
  • opens: a transparent 1×1 pixel is added at the end of the HTML.
  • Links are specific to each recipient (a token signed per message and recipient): none is usable by another. The destination is in the signed token: a link cannot be diverted to another address.
  • Each visit creates an opened or clicked event (detail.url for a click), visible in GET /events, sent to webhooks and counted by GET /stats. At most one event per link, recipient and minute (security bots and prefetching repeat visits). Tracking never changes the status of a message or a recipient.
  • It is not exact: an email client that preloads images or a security gateway that opens links counts as an open or a click. Treat these numbers as indications.
  • Nothing about the visitor is kept: no IP address, no user agent, no referrer.
  • If tracking is turned off after sending, links already sent keep redirecting, recording nothing.

Sending by SMTP

Besides the HTTPS API, Duva accepts messages by SMTP: any software, framework or service that can send email over SMTP (Laravel, Django, Rails, Nodemailer, WordPress, an authentication provider...) can send through Duva without any Duva-specific code. A message received by SMTP follows the same rules as an API message (verified domain, quota, sandbox, suppressed addresses, idempotency, the unusual-volume check) and shows up in the same places: GET /messages/{id}, events, webhooks and statistics.

Connection

Setting Value
Server smtp.duva.ca
Port 587 STARTTLS (recommended)
Port 465 Implicit TLS
Username your sending domain, for example soumissio.ca
Password the API key of that domain (dv_...)
Authentication PLAIN or LOGIN, after encryption
  • Encryption is mandatory: no authentication and no message in clear text. Port 25 is not offered.
  • The key is checked on every message: a revoked or expired key stops working at once, even on an open connection. Every cause of failure gets the same reply (535), without saying which one.

What is read from the message

  • Recipients: those of the SMTP envelope (RCPT TO). The To and Cc headers only classify them; any other address gets a blind copy (bcc). A Bcc header in the message is never read. Same limits as the API (your account's plan), and at most 100 recipients per transaction.
  • Sender: the From header, which must belong to the key's domain (otherwise 553). The envelope address (MAIL FROM) is ignored: Duva uses its own return address.
  • Content: subject, text, HTML, Reply-To, attachments (inline images included, by Content-ID), with the API's limits (10 attachments, 5 MB decoded in total, executable extensions refused, no attachments in the sandbox).
  • Headers: those the API allows (see "Request body"), at most 20; others are ignored.
  • Size: at most 10 MB per message (552).
  • Signed or encrypted messages (S/MIME, PGP/MIME): refused. Duva rebuilds every message, which would break the signature; the DKIM signature is Duva's.

Duva-specific headers

These headers are read and then removed: the recipient never sees them.

Header Purpose
X-Duva-Tags Tags, separated by commas.
X-Duva-Metadata Metadata, as a JSON object (text values).
X-Duva-Track opens, clicks or both, separated by a comma (same conditions as the tracking described below).
X-Idempotency-Key Idempotency key.

Idempotency and retries

Without X-Idempotency-Key, Duva derives one from the Message-ID and the recipients: software that resends the same message after a dropped connection creates no duplicate and gets the same reply. The same Message-ID with other recipients is another message. A 4xx reply invites a retry; a 5xx reply is final for that message.

Replies

250 means the message is accepted for delivery (it is queued), not that it is delivered: follow its status through the API, events or webhooks. The msg_... identifier is in the reply. Duva sends no bounce email back to the sender: a bounce only appears in events.

Reply Meaning
250 Accepted for delivery.
451 Try again later (rate limit or temporary failure).
452 Too many recipients in this transaction.
454 Too many authentication attempts: wait.
530 Authentication (or encryption) required.
535 Invalid credentials (any cause).
550 Refused: quota reached (5.4.5), sending not possible for this account or domain (5.7.1), idempotency conflict (5.5.4).
552 Message too large.
553 Sender outside the key's domain, or invalid address.
554 Message refused: invalid content (for example no body), invalid recipient.

Limits on connections and on refused attempts apply per IP address (421 and 454 replies); they do not get in the way of normal use.

Configure your software

Command-line test with swaks:

swaks --server smtp.duva.ca:587 -tls \
  --auth PLAIN --auth-user soumissio.ca --auth-password "$DUVA_API_KEY" \
  --from [email protected] --to [email protected]

Laravel (.env):

MAIL_MAILER=smtp
MAIL_HOST=smtp.duva.ca
MAIL_PORT=587
MAIL_USERNAME=soumissio.ca
MAIL_PASSWORD=dv_...
[email protected]

Django (settings.py):

EMAIL_HOST = "smtp.duva.ca"
EMAIL_PORT = 587
EMAIL_USE_TLS = True
EMAIL_HOST_USER = "soumissio.ca"
EMAIL_HOST_PASSWORD = os.environ["DUVA_API_KEY"]
DEFAULT_FROM_EMAIL = "[email protected]"

Rails (config/environments/production.rb):

config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: "smtp.duva.ca", port: 587, authentication: :plain, enable_starttls_auto: true,
  user_name: "soumissio.ca", password: ENV.fetch("DUVA_API_KEY")
}

Nodemailer:

const transport = nodemailer.createTransport({
  host: "smtp.duva.ca", port: 587, requireTLS: true,
  auth: { user: "soumissio.ca", pass: process.env.DUVA_API_KEY },
});

For frameworks with an official integration (see libraries and integrations), it also gives access to tags, metadata and tracking without writing headers. The API remains the richest choice: an immediate reply with the status, and reading of messages and events.

Code examples

Complete examples to copy: sending a message and verifying a webhook signature. The API key is read from the DUVA_API_KEY environment variable (a mere convention, not a Duva requirement). Official client libraries exist for Node.js, Python, Ruby, Go and PHP; for any other language, no library is required: the API is a plain JSON POST (curl example), and the OpenAPI specification lets you generate a full client in the language of your choice.

Send a message

Each example sends a text message with an idempotency key, then reads the id and status (202). The official libraries (Node.js, Python, Ruby, Go, PHP) raise a typed exception (or return an error, in Go) on error — see "Errors" — and automatically retry transient failures for this call; the curl example reads the {"error": {"code", "message"}} envelope directly (on a 429, wait Retry-After seconds before retrying).

curl

curl -X POST https://api.duva.ca/v1/example.com/messages \
  -H "Authorization: Bearer $DUVA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-4821" \
  -d '{
    "from": "Example <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order",
    "text": "Thank you for your order."
  }'

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });

const message = await duva.messages.send({
  from: "Example <[email protected]>",
  to: ["[email protected]"],
  subject: "Your order",
  text: "Thank you for your order.",
  idempotencyKey: "order-4821",
});
console.log(message.id, message.status);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")

message = duva.messages.send(
    from_="Example <[email protected]>",
    to=["[email protected]"],
    subject="Your order",
    text="Thank you for your order.",
    idempotency_key="order-4821",
)
print(message.id, message.status)

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");

$message = $duva->messages->send([
    "from" => "Example <[email protected]>",
    "to" => ["[email protected]"],
    "subject" => "Your order",
    "text" => "Thank you for your order.",
    "idempotency_key" => "order-4821",
]);
echo $message->id . " " . $message->status . "\n";

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	result, err := client.Messages.Send(context.Background(), duva.SendMessageParams{
		From:           "Example <[email protected]>",
		To:             []string{"[email protected]"},
		Subject:        "Your order",
		Text:           "Thank you for your order.",
		IdempotencyKey: "order-4821",
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(result.ID, result.Status)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")

result = duva.messages.deliver(
  from: "Example <[email protected]>",
  to: ["[email protected]"],
  subject: "Your order",
  text: "Thank you for your order.",
  idempotency_key: "order-4821"
)
puts "#{result.id} #{result.status}"

Verify a webhook signature

Each official library exposes a function that takes the secret (whsec_…), the request headers and the exact raw body, verifies that the v1 signature is valid and that the timestamp is less than 5 minutes old (replay protection), then raises an exception (or returns an error, in Go) if verification fails — otherwise it returns the already-decoded event. It accepts several secrets (rotation) and several signatures in the header, and compares in constant time.

Node.js (duva-mail)

import { constructEvent, WebhookSignatureError } from "duva-mail";

// req.headers and the raw body exactly as received (not a re-encoded JSON body)
try {
  const event = constructEvent(process.env.DUVA_WEBHOOK_SECRET, req.headers, rawBody);
  console.log(event.type, event.data);
} catch (err) {
  if (err instanceof WebhookSignatureError) {
    res.status(400).end();
    return;
  }
  throw err;
}

Python (duva-mail)

import os

from duva import WebhookSignatureError, construct_event

try:
    event = construct_event(os.environ["DUVA_WEBHOOK_SECRET"], request.headers, raw_body)
    print(event.type, event.data)
except WebhookSignatureError:
    return "", 400

PHP (duva-mail/duva)

<?php
use Duva\Exceptions\WebhookSignatureException;
use Duva\Webhooks;

try {
    $event = Webhooks::constructEvent(getenv("DUVA_WEBHOOK_SECRET"), $headers, $rawBody);
    echo $event->type . " " . ($event->data["message_id"] ?? "");
} catch (WebhookSignatureException) {
    http_response_code(400);
}

Go (duva-go)

package main

import (
	"fmt"
	"net/http"
	"os"

	"github.com/duva-mail/duva-go"
)

func verifyWebhook(headers http.Header, rawBody []byte) {
	event, err := duva.ConstructEvent([]string{os.Getenv("DUVA_WEBHOOK_SECRET")}, headers, rawBody, nil)
	if err != nil {
		// respond 400
		return
	}
	fmt.Println(event.Type, event.Data["message_id"])
}

Ruby (duva-mail)

require "duva"

begin
  event = Duva::Webhooks.construct_event(ENV.fetch("DUVA_WEBHOOK_SECRET"), headers, raw_body)
  puts "#{event.type} #{event.data}"
rescue Duva::WebhookSignatureError
  # respond with 400
end

Read the raw body, before any JSON decoding: re-encoding it changes the bytes and invalidates the signature. For example: express.raw({ type: "application/json" }) (Node.js/Express), request.get_data() (Flask), file_get_contents("php://input") (PHP), io.ReadAll(r.Body) (Go), request.raw_post (Rails). Answer 2xx only after verifying the signature, then de-duplicate on webhook-id.

Health

GET /health: 200 {"status": "ok"}, or 503 if the database is unreachable. No authentication.

curl

curl https://api.duva.ca/health

Node.js (duva-mail)

import { Duva } from "duva-mail";

const duva = new Duva({ apiKey: process.env.DUVA_API_KEY, domain: "example.com" });
const health = await duva.health();
console.log(health.status);

Python (duva-mail)

import os

from duva import Duva

duva = Duva(api_key=os.environ["DUVA_API_KEY"], domain="example.com")
health = duva.health()
print(health["status"])

PHP (duva-mail/duva)

<?php
use Duva\Client;

$duva = new Client(apiKey: getenv("DUVA_API_KEY"), domain: "example.com");
$health = $duva->health();
echo $health["status"];

Go (duva-go)

package main

import (
	"context"
	"fmt"
	"os"

	"github.com/duva-mail/duva-go"
)

func main() {
	client, err := duva.New(os.Getenv("DUVA_API_KEY"), "example.com")
	if err != nil {
		panic(err)
	}

	health, err := client.Health(context.Background())
	if err != nil {
		panic(err)
	}
	fmt.Println(health.Status)
}

Ruby (duva-mail)

require "duva"

duva = Duva::Client.new(api_key: ENV.fetch("DUVA_API_KEY"), domain: "example.com")
health = duva.health
puts health["status"]

Changelog

Date Change
2026-09-27 First publication of the OpenAPI 3.1 specification (/openapi.json), covering every route above.
2026-10-05 Change of behavior, effective from this date. The recipients of a message now see the other to and cc recipients (the To and Cc headers of every copy); before, each copy showed only its own recipient. Do not put several addresses in one to if they must not see each other: send one message per recipient, or use bcc. The cc and bcc fields and the Name <address> format are added (see "Recipients").

A compatible change (a new field, a new enum value, a new route) is never announced here: ignore unknown fields and values rather than rejecting them. A breaking change (a field removed or renamed, a tightened constraint) would be announced here at least 6 months before the old shape stops being accepted.