POST
/emails/send-templatePOST /emails/send-template
Send an email using a template with dynamic variables.
Request
curl -X POST 'https://api.sendheron.com/api/v1/emails/send-template' \
-H 'Authorization: Bearer <YOUR_API_KEY>' \
-H 'Content-Type: application/json'Required scopes
The key must carry these:
- emails:send
Body parameters
| Name | Type | Required | Description |
|---|---|---|---|
| to | string | Yes | Recipient address. |
| templateId | uuid | Yes | Template to render. |
| variables | object | No | Values for the template's Handlebars variables. Merged with the auto-populated contact.* set when contactId is given. |
| contactId | uuid | No | Fills in contact.firstName, contact.lastName, contact.email and each custom property as contact.<key>. For a MARKETING template it also names the contact whose unsubscribe link the mail carries; transactional mail carries none. Send it whenever the recipient is a known contact. |
| subject | string | No | Overrides the template's subject. Takes the same variables. |
| from | string | No | Sender address. Defaults to the workspace's default sender identity; with none configured, the send is suppressed with SENDER_NOT_CONFIGURED. |
| fromName | string | No | Display name for this send, shown beside the address and overriding the sender identity's. The address is what domain verification gates, so the name may vary per send: one verified address, several product names. At most 100 characters, with no line breaks and no angle brackets. |
| replyTo | string | No | Reply-To address. |
| cc | string[] | No | Up to 50 addresses. Each is checked against the suppression list on its own, because a cc is a real delivery, and a blocked copy is dropped without failing the message. |
| bcc | string[] | No | Up to 50 addresses, suppression-checked the same way, and a blocked copy is likewise dropped rather than failing the message. |
| sendAt | ISO-8601 datetime | No | Schedule the send instead of dispatching it now. Must be in the future. The template REFERENCE and the variables are stored, and rendering, the contact merge, and every compliance and plan gate run when it fires, so the recipient gets the template as it exists then. Cancel before it fires with DELETE /emails/scheduled/{id}. Cannot be combined with attachments. |
| attachments | object[] | No | At most 10 per message and 10 MB of decoded bytes across all of them together, in the same shape SendGrid takes. Only allowed when the template is TRANSACTIONAL. Cannot be combined with sendAt. The nested fields are documented below. |
| attachments[].content | string | Yes | File content, base64-encoded. The bytes are passed straight to the provider and never stored, and they are deliberately excluded from the idempotency-key payload comparison: a retried job that regenerated the same file (a fresh creation timestamp inside the PDF) replays the original response instead of failing the key with a 409. filename and type still count towards that comparison. |
| attachments[].filename | string | Yes | Filename shown to the recipient, e.g. receipt-1234.pdf. |
| attachments[].type | string | Yes | MIME type, from the allowlist: application/pdf, image/png, image/jpeg, image/gif, image/webp, text/calendar, text/csv, text/plain, application/zip. Anything else is refused. |
| attachments[].disposition | string | No | Only attachment is accepted. The field exists for SendGrid payload compatibility; inline (CID) images are not supported. |
Responses
Returns 201 on success.
- 400
- The payload failed validation, or the request is not valid for the current state. The body's
descriptionnames the field and says what was wrong with it in words, e.g. "domain: domain must be a bare hostname such as acme.com, with no scheme, path, or port". Theerrorcode is stable and safe to branch on;descriptionis for the human reading the log. - 401
- Missing or invalid API key.
- 403
- Valid key, but it does not carry the required scope. The
errorcode isapiKeys.insufficientScopesand thedescriptionnames both halves of the problem: "Missing required scope(s): X. This key holds: Y." Neither is a secret, since the required scopes are on this page and the held ones are your own credential, and a bare "Forbidden" costs a debugging pass to work out which of the two it was. - 404
- No such record in this workspace. An id belonging to a different workspace returns this too, never a 403, because the API will not confirm that a record exists outside the workspace your key was issued in. Read it as "not yours or not there" rather than as "definitely gone".
- 409
- A duplicate, or an idempotency key reused with a different payload.
- 429
- Either rate-limit ceiling was exceeded.
- 400
emailSending.marketingRequiresContact: the template is marketing-typed andtois not a contact in the workspace. The one-click unsubscribe token is minted from a contact record, and marketing without a working unsubscribe is refused rather than shipped. Create the contact first, or passcontactId.- 400
emailSending.attachmentsRequireTransactional: the template is marketing-typed and the request carries attachments. Marketing mail does not get MIME attachments, because attachments on bulk mail are a deliverability red flag.- 400
templates.renderFailed: the template failed to compile, so the send is refused rather than mailing its raw{{...}}source.- 400
emailSending.tooManyAttachments,emailSending.attachmentsTooLargeoremailSending.attachmentTypeNotAllowed: an attachment cap or the MIME allowlist was breached. The keys are stable, so branch on them rather than on the text.- 400
emailSending.attachmentsNotSchedulable:attachmentsandsendAtin the same request. Attachment bytes are never stored, so a scheduled send would have nothing left to attach when it fires.- 403
subscription.sendPoolExhausted: the template is marketing-typed and the pooled monthly send allowance is spent. The pool resets with the calendar month; a higher tier raises it.- 403
subscription.transactionalGraceExhausted: the template is transactional-typed and the pool plus the 10% transactional fair-use grace is spent.- 403
subscription.lapsed: no live subscription. A marketing-typed send is always refused while lapsed. A transactional-typed send is refused only if the organization has never paid; one that has paid before keeps the smallest plan's grace, so its password resets survive a billing problem.- 403
compliance.impersonation: until your account has been reviewed, a sender name or subject that names a well-known brand is refused outright, and no send record is written. It stops applying once the account is approved.- 503
emailSending.sendFailed: the provider refused the send. The FAILED attempt is recorded and the idempotency key is released, so retry with the SAME key.- 503
emailSending.sendHeld: the send carries attachments and would have had to wait for daily sending capacity, new-sender warm-up or the checks on an old imported list. Attachment bytes are never stored, so it cannot wait. The FAILED attempt is recorded, with the reason it would have waited inerrorMessage, and the idempotency key is released, so retry later with the SAME key.
Rate limits
100/min per key, counted against the organization's SEND ceiling (200/min). Both windows are one minute. They limit requests; how much mail goes out in a day is under Daily sending.