Enhanced Reports API Integration
Access PostGrid's enhanced reports via the API to run ad-hoc SQL queries, schedule saved reports, and export mail analytics data programmatically.
View advanced built-in reports and create your own without any additional infrastructure.
One of PostGrid’s main advantages over traditional direct mail companies is visibility. You’re able to monitor the status of each individual mailer, get insights into its delivery, and export that data whenever you need it.
Enhanced reports extend PostGrid’s capabilities to provide more detailed analytics (e.g. delivery velocity, mail categories, etc) and to let you create your own custom reports. Not only that, you can access these reports using our API as well as our dashboard, allowing you to build automations on top of them.
Reach out to support@postgrid.com to get access.
Every table and column you can query is listed in the data lake schema reference.
Ad-Hoc Reports
Section titled “Ad-Hoc Reports”One way to use PostGrid’s enhanced reports system is to run ad-hoc SQL queries
against your data lake. This lets get quick insights into your data without
setting up a report. Here’s a query that gets the id and status of all
letters sent to a contact with a particular first name:
select l.id, l.status from letters ljoin contacts c on c.id = l.to_idwhere c.firstName = ?Let’s run this query using the API to find all the letters sent to Kevin
(API Reference)
curl --location 'https://api.postgrid.com/print-mail/v1/reports/samples' \--header 'x-api-key: {YOUR_API_KEY}' \--header 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'sqlQuery=select l.id, l.status from letters ljoin contacts c on c.id = l.to_idwhere c.firstName = ?' \--data-urlencode 'params[]=Kevin' \--data-urlencode 'limit=5'Responds with
{ "id": "report_sample_qWvJDT7PCtpSw499WQfBx3", "object": "report_sample", "records": [ { "id": "letter_bYuofk7EVPupccaMqitzG8", "status": "completed" }, { "id": "letter_qJD9FaoHCrLuW9AsmtnMBP", "status": "completed" }, { "id": "letter_44su5CvLkWQPd1ptoBHK4J", "status": "completed" }, { "id": "letter_cezhbzY9s4Yk9art23f5gD", "status": "completed" }, { "id": "letter_4d4fnfFTb1SU1QgNrSaJvq", "status": "completed" } ]}These are letter IDs and statuses for letters that are sent to somebody with the
first name Kevin. We’re passing Kevin in as the first binding to the SQL
query via params, preventing any SQL injection. This means you can safely pass
untrusted data into params – e.g. letting your users query for all of their
letters by first name.
Whilst the query shown above could be replicated using PostGrid’s structured search API , you can perform much more advanced queries using SQL. Here’s another example where we get the average page count of all our letters (API Reference)
curl --location 'https://api.postgrid.com/print-mail/v1/reports/samples' \--header 'x-api-key: {YOUR_API_KEY}' \--header 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'sqlQuery=select avg(pageCount) from letters'Responds with
{ "id": "report_sample_rsmfyTfMPZSsNGBKP9wCAG", "object": "report_sample", "records": [ { "avg(pageCount)": 2.1316224253772442 } ]}These are just some examples of what you can do with ad-hoc SQL queries. You have access to all the querying capabilities of DuckDB as documented in the DuckDB SQL introduction.
Limitations
Section titled “Limitations”These ad-hoc reports can only run for a maximum of 30s and return at most 1000
rows (you can specify at most 1000 for limit). Also, you can only run 5
queries per minute. Please reach out to
support@postgrid.com if you would like a higher
quota on any of these values.
Also, the data provided in these reports may be up to 2 hours behind your latest
PostGrid data. If you need real-time data, we recommend listening to our
webhooks or polling our GET endpoints.
Saved Reports
Section titled “Saved Reports”Once you’ve sampled a query, you may want to save it for future use, or you may need more than just 1000 rows of data.
You can create a saved report by hitting the POST /reports endpoint
(API Reference)
curl --location 'https://api.postgrid.com/print-mail/v1/reports' \--header 'x-api-key: {YOUR_API_KEY}' \--header 'Content-Type: application/json' \--data '{ "sqlQuery": "select * from letters where sendDate > ?::TIMESTAMP and sendDate < ?::TIMESTAMP"}'This query returns all the letters which have a sendDate within a given time
range. The response includes a report ID and other details
{ "id": "report_8BriPzDkDChoUDZuBYcwb8", "object": "report", "live": false, "sqlQuery": "select * from letters where sendDate > ?::TIMESTAMP and sendDate < ?::TIMESTAMP", "createdAt": "2025-11-11T19:38:15.819Z", "updatedAt": "2025-11-11T19:38:15.819Z"}You can sample this report by hitting
POST /reports/report_8BriPzDkDChoUDZuBYcwb8/samples same as before.
(API Reference)
You can also initiate a report export which lets you export up to 100 megabytes of data from PostGrid (API Reference)
curl --location 'https://api.postgrid.com/print-mail/v1/reports/report_8BriPzDkDChoUDZuBYcwb8/exports' \--header 'x-api-key: {YOUR_API_KEY}' \--header 'Content-Type: application/json' \--data '{ "params": ["2025-04-01", "2025-04-30"]}'Which responds with
{ "id": "report_export_3cyBHymXp6R2HLiHJjWpDn", "object": "report_export", "live": false, "params": ["2025-04-01", "2025-04-30"], "report": { "id": "report_cfUj47hyjWfoQP9boGFKsL", "sqlQuery": "select * from letters where sendDate > ?::TIMESTAMP and sendDate < ?::TIMESTAMP" }, "createdAt": "2025-11-11T19:41:17.114Z", "updatedAt": "2025-11-11T19:41:17.114Z"}You can then poll this for completion by hitting the
GET /reports/report_cfUj47hyjWfoQP9boGFKsL/exports/report_export_3cyBHymXp6R2HLiHJjWpDn
endpoint (API Reference)
{ "id": "report_export_3cyBHymXp6R2HLiHJjWpDn", "object": "report_export", "live": false, "outputURL": "https://pg-prod-bucket-1.s3.amazonaws.com/report-exports/test/org_aSFrdfG2QGnS6vzx3BSAZt/report_export_3cyBHymXp6R2HLiHJjWpDn.csv", "params": ["2025-04-01", "2025-04-30"], "report": { "id": "report_cfUj47hyjWfoQP9boGFKsL", "sqlQuery": "select * from letters where sendDate > ?::TIMESTAMP and sendDate < ?::TIMESTAMP" }, "rowCount": 186, "sizeInBytes": 106413, "createdAt": "2025-11-11T19:41:17.114Z", "updatedAt": "2025-11-11T19:41:22.621Z"}Once there is an outputURL populated, you can use it to download the CSV file
with all your data.
To see every export you’ve taken from a report, list them with
GET /reports/report_cfUj47hyjWfoQP9boGFKsL/exports
(API Reference). They come
back newest first.
Limitations
Section titled “Limitations”As mentioned before, you can only export up to 100mb of data in that CSV file. PostGrid will truncate anything past that point. Moreover, your export can only be processed for a maximum of 13 minutes. Poorly structured queries that process 100s of millions of orders are likely to exceed this limit, so leverage parameters to scope down your queries as needed.
You can start 5 exports per minute. As with sampling, reach out to support@postgrid.com if you need a higher quota.
Scheduled Reports
Section titled “Scheduled Reports”A saved report can run itself on a schedule and email your team when each run
finishes, so you don’t have to trigger exports yourself. Pass a schedule when
you create the report
(API Reference)
curl --location 'https://api.postgrid.com/print-mail/v1/reports' \--header 'x-api-key: {YOUR_API_KEY}' \--header 'Content-Type: application/json' \--data '{ "description": "Weekly letter volume", "sqlQuery": "select status, count(*) as orders from letters where sendDate > CURRENT_DATE - INTERVAL 7 DAY group by status", "schedule": { "interval": 1, "unit": "w", "time": "09:00", "dayOfWeek": 1, "notificationEmails": ["analytics@example.com"] }}'Responds with
{ "id": "report_8BriPzDkDChoUDZuBYcwb8", "object": "report", "live": false, "description": "Weekly letter volume", "sqlQuery": "select status, count(*) as orders from letters where sendDate > CURRENT_DATE - INTERVAL 7 DAY group by status", "schedule": { "frequency": "1w", "nextRun": "2026-06-15T13:00:00.000Z", "notificationEmails": ["analytics@example.com"], "description": "Every week on Monday at 9:00 AM (America/Toronto)", "spec": { "interval": 1, "unit": "w", "time": "09:00", "dayOfWeek": 1, "month": 5, "dayOfMonth": 15, "notificationEmails": ["analytics@example.com"] } }, "createdAt": "2026-06-08T19:38:15.819Z", "updatedAt": "2026-06-08T19:38:15.819Z"}Every run creates a regular report export under the report, exactly as if you
had called POST /reports/{id}/exports yourself, so you can list them with
GET /reports/{id}/exports and download each one from its outputURL.
Schedule fields
Section titled “Schedule fields”| Field | Description |
|---|---|
interval | How many units pass between runs — the 2 in “every 2 weeks”. Must be at least 1. |
unit | h (hour), d (day), w (week), m (month), or y (year). |
time | 24-hour HH:mm run time, interpreted in America/Toronto. |
dayOfWeek | Weekly schedules only. 0 (Sunday) through 6 (Saturday). |
dayOfMonth | Monthly and yearly schedules only. Monthly schedules are capped at 28 so they never skip a short month. |
month | Yearly schedules only. 0 (January) through 11 (December). |
notificationEmails | Up to 10 addresses to email when a run finishes. Omit it, or pass an empty array, to send no email. |
Fields that don’t apply to the unit you picked are ignored, so a daily or
hourly schedule needs only interval, unit, and time.
To change a schedule, pass a new schedule to POST /reports/{id}. To stop a
report from running on its own, pass "schedule": null.
If you want to confirm a schedule before saving it,
POST /reports/schedule_previews takes the same fields and returns the cadence
PostGrid derived from them — including nextRun, a human-readable
description, and a firstRunLabel — without creating anything.
Notification emails
Section titled “Notification emails”Each address in notificationEmails must belong to a user in the same
organization as the report; any address that doesn’t is skipped. This keeps
report data from being mailed outside your team. Both outcomes are reported: a
successful run emails a link to the report in the dashboard, and a failed run
emails a notice to check the report.
Limitations
Section titled “Limitations”A scheduled report cannot use query parameters. Nothing supplies their
values when the report runs on its own, so saving a schedule against a
parameterized query fails with a scheduled_report_parameters_error. Use
DuckDB’s date and time functions (CURRENT_DATE, NOW(), and friends) for the
dynamic parts instead, as in the query above.
The smallest cadence is one hour. PostGrid checks for due reports every 15 minutes, so a run may start up to 15 minutes after the time you picked. If your data lake isn’t ready when a run comes due, that run is skipped and retried on the next cycle, leaving the schedule where it was.
Scheduled runs are subject to the same export limits described above: 100 megabytes of output and 13 minutes of runtime.
Querying across sub-organizations
Section titled “Querying across sub-organizations”If you are a parent organization with sub-organizations, PostGrid can fold your sub-organizations’ data into your own data lake. One query then covers your whole hierarchy — no per-sub-organization credentials, and no stitching results together afterwards.
This is off by default. Ask support@postgrid.com to turn it on; it is an organization-wide setting, so it applies to every sub-organization under you rather than being enabled one at a time. Sub-organizations and impersonation must already be enabled for your organization.
Once it’s on, every table in your data lake carries rows from your
sub-organizations alongside your own, and the organization column on each row
tells you which organization it belongs to. There’s also an organizations
table — your organization plus each sub-organization, with id and name — so
you can label results without hardcoding IDs:
select o.name, count(*) as lettersfrom letters ljoin organizations o on o.id = l.organizationwhere l.sendDate > CURRENT_DATE - INTERVAL 30 DAYgroup by o.nameorder by letters descTo narrow a query to one sub-organization, filter on organization directly:
select id, status from letters where organization = ?Note that the organization column holds the underlying organization ID
(org_...), whereas the sub-organizations API returns IDs prefixed with
sub_org_. Drop the sub_ prefix when you pass one into a query: the
sub-organization sub_org_aSFrdfG2QGnS6vzx3BSAZt is
org_aSFrdfG2QGnS6vzx3BSAZt in your data lake. Joining against the
organizations table by name avoids the conversion entirely.
Until this is enabled, your data lake contains only your own organization’s
records, and the organization column is the same value on every row.
Scoping a request to one sub-organization
Section titled “Scoping a request to one sub-organization”Reports also honour the PostGrid-Org header used across the rest of the API
(see
Impersonating your Sub-Organizations).
Sending it scopes the entire call to that sub-organization:
curl --location 'https://api.postgrid.com/print-mail/v1/reports/samples' \--header 'x-api-key: {YOUR_API_KEY}' \--header 'PostGrid-Org: sub_org_aSFrdfG2QGnS6vzx3BSAZt' \--header 'Content-Type: application/x-www-form-urlencoded' \--data-urlencode 'sqlQuery=select count(*) from letters'This is a different thing from the consolidated data lake described above, and the two are worth keeping straight:
- Consolidated data lake — you query your own data lake, which happens to contain your sub-organizations’ rows. Saved reports and exports belong to your organization, and a single query can span the whole hierarchy.
PostGrid-Orgheader — you query that sub-organization’s data lake, and the saved reports and exports you create belong to the sub-organization, not to you. Enhanced reports must be enabled on that sub-organization for its data lake to exist, and a query can only see the one sub-organization you scoped to.