Deliver to Amazon SQS
Route delivered events into an Amazon SQS queue. Emithook delivers from its own IAM principal and you grant it access with a queue policy — there are no per-destination access keys to create, rotate, or leak.
How delivery works
Emithook signs every delivery with a single, shared IAM principal (its own AWS account). To let it write to your queue, you add one statement to the queue's resource-based access policy granting that principal permission. This is standard AWS cross-account access — the same model SNS→SQS and EventBridge→SQS use.
emithook delivery worker ──(SendMessage)──▶ your SQS queue
principal: arn:aws:iam::589958197158:user/emithook
authorized by: your queue's Access policyEmithook needs two actions:
sqs:SendMessage— deliver each event to the queue.sqs:GetQueueAttributes— the console's Reachable check calls this to confirm the grant took effect before you route traffic.
1. Find your queue URL and ARN
In the SQS console, open your queue. The URL looks like:
https://sqs.us-east-1.amazonaws.com/123456789012/my-queueIts ARN is the same three parts rearranged:
arn:aws:sqs:us-east-1:123456789012:my-queue(region → account id → queue name). The emithook console derives this ARN from the URL for you.
2. Add the queue policy
In the SQS console: your queue → Access policy → Edit, and paste the statement below (merge it into an existing policy's Statement array if you already have one). Replace Resource with your queue's ARN.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EmithookDelivery",
"Effect": "Allow",
"Principal": { "AWS": "arn:aws:iam::589958197158:user/emithook" },
"Action": ["sqs:SendMessage", "sqs:GetQueueAttributes"],
"Resource": "<your-queue-arn>"
}
]
}Save the policy.
Region and ARN must match
The queue URL you register in emithook, its ARN in the policy, and the queue itself must all be in the same region. A mismatched region is the most common cause of a Queue not found reachability result.
Self-hosting
The principal ARN above is emithook's managed delivery identity. A self-hosted deployment publishes its own via SQS_DELIVERY_PRINCIPAL_ARN — the console always shows the correct ARN for the deployment you're using, so copy it from there rather than from this page.
3. Create the destination in emithook
In the console: Destinations → New destination → Amazon SQS, enter your queue URL, and copy the pre-filled queue policy (it already has the right principal ARN and your derived queue ARN). The access-key fields are gone — cross-account grants replace them.
Prefer the API? Create the destination with just the queue URL:
curl -X POST https://api.emithook.com/v1/destinations \
-H "Authorization: Bearer $EK_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "orders-queue",
"type": "sqs",
"config": { "url": "https://sqs.us-east-1.amazonaws.com/123456789012/my-queue" }
}'4. Verify with the Reachable indicator
Back in the console, the destination shows a reachability badge. Click Re-check — emithook calls GetQueueAttributes against your queue:
- Reachable ✓ — the policy is in effect; you can route endpoints to this destination.
- Not reachable ✗ with
Access denied— the queue policy hasn't granted access yet (or is still propagating). Re-check after saving the policy. - Not reachable ✗ with
Queue not found— the URL or region is wrong; confirm the queue exists in that region.
Once it reads Reachable, add the destination to an endpoint's routes and events start flowing to your queue.
5. Consume the messages
Every event arrives as one SQS message whose MessageBody is the delivery envelope as JSON: the request's metadata, its parsed body, headers and query string.
- No message attributes and no signature. Queue deliveries aren't signed: your queue policy already decides who can write to the queue. (HTTPS deliveries carry
webhook-*signature headers; queue messages don't.) - At least once. A message can arrive more than once (a retry after a timeout, a redelivery).
metadata.event_idis the same every time, so use it to drop duplicates. - Replays reuse
event_id. A replay re-sends the same event with a newmetadata.posted_atand no other marker. If you de-duplicate onevent_id, a replayed event is dropped too, so if you want replays reprocessed, make the handler an idempotent upsert instead. - FIFO queues (URL ending in
.fifo) getMessageGroupId: "default"and a uniqueMessageDeduplicationIdper send, so content-based deduplication has no effect and all events to that destination keep one order. - Size. The whole envelope must fit the queue's message size limit. An event whose envelope is too large fails, is retried, and ends in the DLQ.
- Inbound email arrives as a notice. An email routed to a queue doesn't carry the mail:
data.bodyis anemail.receivednotice (sender, recipients, subject, attachment list) with API URLs to fetch the content. See Receive email into SQS.
What a message looks like
A Shiprocket status update received on an endpoint named "Shiprocket orders". Shiprocket sends charge_info as a string containing JSON; Emithook parses it and lists it in expanded_json_paths:
{
"version": 2,
"metadata": {
"event_id": "01M2XHCDW3H02X0AZ0998JJYC5",
"identifier": "ep_01M0Z3B3DB4W93V3VJMMVKTXGZ",
"name": "Shiprocket orders",
"source": "https",
"url": "https://wh.acme.in/ar/shiprocket",
"method": "POST",
"path": "/ar/shiprocket",
"content_type": "application/json",
"body_encoding": "json",
"size": 2351,
"source_ip": "203.0.113.74",
"verification": { "status": "unverified", "scheme": "none" },
"transformed": false,
"captured_at": "2026-09-19T19:13:10.531Z",
"posted_at": "2026-09-19T19:13:10.804Z",
"expanded_json_paths": ["/charge_info"]
},
"data": {
"body": {
"awb": "77957065756",
"current_status": "IN TRANSIT",
"current_status_id": 20,
"order_id": "#380974",
"sr_order_id": 1590238168,
"charge_info": {
"zone_info": { "is_mongo": true, "is_old_zone": false, "on_new_zone": 0 },
"other_charges": 0
},
"scans": [
{ "date": "2026-09-16 17:01:00", "status": "501-S-PU", "activity": "PICKUP EMPLOYEE IS OUT TO P/U SHIPMENT", "sr-status-label": "OUT FOR PICKUP" }
]
},
"headers": {
"content-type": "application/json",
"user-agent": "curl/8.5.0",
"x-api-key": "<the provider's key, forwarded as sent>"
},
"queries": {}
}
}data.body depends on metadata.body_encoding. The other three shapes:
{
"metadata": { "content_type": "application/x-www-form-urlencoded", "body_encoding": "form",
"expanded_json_paths": ["/payload"], "…": "…" },
"data": {
"body": {
"payload": { "type": "block_actions", "user": { "id": "U01ABC" }, "actions": [{ "action_id": "approve" }] }
},
"headers": { "content-type": "application/x-www-form-urlencoded", "x-slack-signature": "v0=…" },
"queries": {}
}
}{
"metadata": { "content_type": "text/plain", "body_encoding": "text", "expanded_json_paths": [], "…": "…" },
"data": { "body": "order 380974 shipped", "headers": { "content-type": "text/plain" }, "queries": {} }
}{
"metadata": { "body_encoding": "json", "transformed": true, "expanded_json_paths": [], "…": "…" },
"data": {
"body": { "orderId": "#380974", "status": "IN_TRANSIT" },
"headers": { "content-type": "application/json" },
"queries": {}
},
"original": {
"body": { "order_id": "#380974", "current_status": "IN TRANSIT", "charge_info": "{\"zone_info\": …}" },
"body_encoding": "json",
"headers": { "content-type": "application/json" }
}
}A base64 body is the raw bytes, base64-encoded (a file or other binary upload). When a transform ran, data.body is exactly what it returned (nothing expanded) and original carries the request as it arrived.
Types
Copy these into your consumer:
export type BodyEncoding = 'json' | 'form' | 'text' | 'base64'
export interface EmithookEnvelope<TBody = unknown> {
version: 2
metadata: {
/** Same on every retry/replay of one event — your idempotency key. */
event_id: string
/** Endpoint id (ep_…); for an email with no endpoint, the alias id. Route on this. */
identifier: string | null
/** Endpoint name (falls back to its URL path); for email, the alias address. */
name: string | null
/** 'https' | 'email' | 'queue' | 'send-api' | … */
source: string
url: string | null
method: string | null
path: string | null
/** The inbound request's content type. */
content_type: string | null
body_encoding: BodyEncoding
/** Inbound body size in bytes. */
size: number
source_ip: string | null
verification: { status: 'verified' | 'failed' | 'unverified'; scheme: string }
/** true ⇔ `original` is present. */
transformed: boolean
/** When Emithook received the request (ISO-8601). */
captured_at: string
/** When Emithook posted THIS delivery (ISO-8601); new on every retry/replay. */
posted_at: string
/** JSON Pointers of body strings that held JSON and were parsed in place. */
expanded_json_paths: string[]
}
data: {
/** json → parsed value · form → Record<string, string | string[] | object> · text → string · base64 → string */
body: TBody
/** Inbound headers minus authorization / cookie / set-cookie / proxy-authorization. */
headers: Record<string, string>
/** Query string, one value per key; {} when none. */
queries: Record<string, string>
}
/** Only when a transform ran: the request as it arrived. */
original?: { body: unknown; body_encoding: BodyEncoding; headers: Record<string, string> }
}Type each provider's payload on top of it, e.g. EmithookEnvelope<ShiprocketStatus>.
Example: a Node.js consumer
With the AWS SDK v3. It routes by endpoint, drops duplicates, and deletes a message only after it was handled:
import { SQSClient, ReceiveMessageCommand, DeleteMessageCommand } from '@aws-sdk/client-sqs'
import type { EmithookEnvelope } from './emithook-envelope'
const sqs = new SQSClient({ region: 'ap-south-1' })
const QueueUrl = process.env.QUEUE_URL!
interface ShiprocketStatus {
awb: string
order_id: string
current_status: string
charge_info?: { other_charges: number; zone_info?: Record<string, unknown> }
}
const handlers: Record<string, (env: EmithookEnvelope<any>) => Promise<void>> = {
// key = metadata.identifier (the endpoint id)
ep_01M0Z3B3DB4W93V3VJMMVKTXGZ: async (env: EmithookEnvelope<ShiprocketStatus>) => {
const s = env.data.body
await updateShipment(s.order_id, s.awb, s.current_status, s.charge_info?.other_charges ?? 0)
},
}
while (true) {
const { Messages = [] } = await sqs.send(
new ReceiveMessageCommand({ QueueUrl, MaxNumberOfMessages: 10, WaitTimeSeconds: 20 }),
)
for (const m of Messages) {
const env = JSON.parse(m.Body!) as EmithookEnvelope
if (env.version !== 2) throw new Error(`unsupported envelope version ${env.version}`)
if (env.metadata.body_encoding !== 'json') {
console.warn('non-JSON body from', env.metadata.name, env.metadata.body_encoding)
}
if (!(await seenBefore(env.metadata.event_id))) {
await (handlers[env.metadata.identifier ?? ''] ?? logUnrouted)(env)
await markSeen(env.metadata.event_id)
}
await sqs.send(new DeleteMessageCommand({ QueueUrl, ReceiptHandle: m.ReceiptHandle! }))
}
}Example: a NestJS DTO
With class-validator and class-transformer:
import { Type } from 'class-transformer'
import {
Allow, Equals, IsArray, IsBoolean, IsDateString, IsIn, IsInt, IsObject,
IsOptional, IsString, Min, ValidateIf, ValidateNested,
} from 'class-validator'
const ENCODINGS = ['json', 'form', 'text', 'base64'] as const
const nullable = ValidateIf((_o, v) => v !== null)
export class VerificationDto {
@IsIn(['verified', 'failed', 'unverified']) status!: 'verified' | 'failed' | 'unverified'
@IsString() scheme!: string
}
export class EnvelopeMetadataDto {
@IsString() event_id!: string
@nullable @IsString() identifier!: string | null
@nullable @IsString() name!: string | null
@IsString() source!: string
@nullable @IsString() url!: string | null
@nullable @IsString() method!: string | null
@nullable @IsString() path!: string | null
@nullable @IsString() content_type!: string | null
@IsIn(ENCODINGS) body_encoding!: (typeof ENCODINGS)[number]
@IsInt() @Min(0) size!: number
@nullable @IsString() source_ip!: string | null
@ValidateNested() @Type(() => VerificationDto) verification!: VerificationDto
@IsBoolean() transformed!: boolean
@IsDateString() captured_at!: string
@IsDateString() posted_at!: string
@IsArray() @IsString({ each: true }) expanded_json_paths!: string[]
}
export class EnvelopeDataDto<TBody = unknown> {
/** Shape depends on metadata.body_encoding — validate per provider after routing. */
@Allow() body!: TBody
@IsObject() headers!: Record<string, string>
@IsObject() queries!: Record<string, string>
}
export class EnvelopeOriginalDto {
@Allow() body!: unknown
@IsIn(ENCODINGS) body_encoding!: (typeof ENCODINGS)[number]
@IsObject() headers!: Record<string, string>
}
export class EmithookEnvelopeDto<TBody = unknown> {
@Equals(2) version!: 2
@ValidateNested() @Type(() => EnvelopeMetadataDto) metadata!: EnvelopeMetadataDto
@ValidateNested() @Type(() => EnvelopeDataDto) data!: EnvelopeDataDto<TBody>
@IsOptional() @ValidateNested() @Type(() => EnvelopeOriginalDto) original?: EnvelopeOriginalDto
}// in your SQS handler
const env = plainToInstance(EmithookEnvelopeDto, JSON.parse(message.Body))
await validateOrReject(env) // NOT { whitelist: true } / forbidNonWhitelisted — see belowExample: a Python consumer
import json, boto3
sqs = boto3.client("sqs", region_name="ap-south-1")
QUEUE_URL = "https://sqs.ap-south-1.amazonaws.com/123456789012/orders"
while True:
resp = sqs.receive_message(QueueUrl=QUEUE_URL, MaxNumberOfMessages=10, WaitTimeSeconds=20)
for m in resp.get("Messages", []):
env = json.loads(m["Body"])
assert env["version"] == 2
meta, data = env["metadata"], env["data"]
if not seen_before(meta["event_id"]):
if meta["identifier"] == "ep_01M0Z3B3DB4W93V3VJMMVKTXGZ" and meta["body_encoding"] == "json":
body = data["body"]
update_shipment(body["order_id"], body["awb"], body["current_status"],
body.get("charge_info", {}).get("other_charges", 0))
mark_seen(meta["event_id"])
sqs.delete_message(QueueUrl=QUEUE_URL, ReceiptHandle=m["ReceiptHandle"])Consumer checklist
- Route on
metadata.identifier, notnameorurl: it stays the same if the endpoint is renamed or moved to another domain. - Check
metadata.body_encodingbefore readingdata.body. A provider that sends malformed JSON arrives astext, not as an object. - Don't reject unknown fields. Version 2 can gain new
metadatafields (asposted_atandexpanded_json_pathswere added). Reject only a differentversion. - Treat
data.headersas sensitive. Onlyauthorization,cookie,set-cookieandproxy-authorizationare removed; a provider's own key header (such asx-api-key) is forwarded as sent, so don't log headers wholesale. - Latency from receipt to your queue is
posted_at − captured_at. - Expanded strings are values, not text. If you verify a provider signature over a string that Emithook expanded (listed in
expanded_json_paths), rebuild it from the original request in the request log instead. See the delivery envelope.