Skip to content
Get startedDashboardSupport

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.

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 l
join contacts c on c.id = l.to_id
where c.firstName = ?

Let’s run this query using the API to find all the letters sent to Kevin (API Reference)

Terminal window
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 l
join contacts c on c.id = l.to_id
where 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)

Terminal window
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.

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.

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)

Terminal window
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)

Terminal window
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.

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.

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)

Terminal window
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.

FieldDescription
intervalHow many units pass between runs — the 2 in “every 2 weeks”. Must be at least 1.
unith (hour), d (day), w (week), m (month), or y (year).
time24-hour HH:mm run time, interpreted in America/Toronto.
dayOfWeekWeekly schedules only. 0 (Sunday) through 6 (Saturday).
dayOfMonthMonthly and yearly schedules only. Monthly schedules are capped at 28 so they never skip a short month.
monthYearly schedules only. 0 (January) through 11 (December).
notificationEmailsUp 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.

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.

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.

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 letters
from letters l
join organizations o on o.id = l.organization
where l.sendDate > CURRENT_DATE - INTERVAL 30 DAY
group by o.name
order by letters desc

To 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.

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:

Terminal window
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-Org header — 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.