Updated October 7, 2026
Salesforce lets you create a real, native report from code. You send a report definition to the Reports and Dashboards REST API, Salesforce saves it, and it shows up in the Reports tab like anything a user built by hand. No exports, no shadow tables, just a report your team can open, run, and edit.
We recently built a tool that does exactly this. A user describes the report they want in plain English, and the tool turns that request into a report definition and saves it to the org. The language side turned out to be the easy part. Nearly every hard bug lived in the report definition itself, in details the API documentation either skips or buries.
This post covers what we hit, what the working payloads actually look like, and how we would test this kind of integration next time.
The Basic Shape of a Create Call
You create a report with a POST to the analytics reports endpoint, sending a reportMetadata object. The Report List resource documents POST as the way to create or clone a report:
POST /services/data/v62.0/analytics/reports
{
"reportMetadata": {
"name": "Active Accounts Without Open Opps",
"reportFormat": "TABULAR",
"reportType": { "type": "AccountList" },
"detailColumns": ["ACCOUNT.NAME", "TYPE", "ADDRESS1_STATE_CODE"],
"reportFilters": [],
"scope": "organization"
}
}
That looks simple. Almost every field in it has a trap.
Columns Are Tokens, Not Field Names
This is the one that cost us the most time. The detailColumns array does not take field API names. It takes report type column tokens, and the tokens are not something you can derive from the API name.
On the standard Accounts report type, these are the real tokens we confirmed against a live org:
NamebecomesACCOUNT.NAMETypebecomesTYPE, notACCOUNT.TYPEBillingStatebecomesADDRESS1_STATE_CODE- A custom field like
Active__cbecomesAccount.Active__c
There is no pattern you can code against there. Send ACCOUNT.TYPE and you get back an error telling you the column is not valid, even though it looks like it follows the same convention as ACCOUNT.NAME.
The fix is to stop guessing and ask Salesforce. The Report Type resource returns metadata about a report type, including every available column along with its token:
GET /services/data/v62.0/analytics/reportTypes/AccountList
In our responses, each column carried its token as the key and a fullyQualifiedName in the form Entity.FieldApiName, for example Account.BillingState. Build a map from fullyQualifiedName to token once, then translate every field your code wants to use before you POST. The same map works for filter columns and grouping columns, which also take tokens.
The upside of doing it this way is that you also get validation for free. If a field is not in the map, it is not available on that report type, and you can say so clearly instead of passing along a vague 400 from the API.
Cross Filters Use includesObject
A lot of useful business questions are cross filter reports. “Active accounts without an open opportunity” is the classic one. The Report Builder calls these cross filters, and the API supports them through a crossFilters array.
The key that decides “with” versus “without” is a boolean called includesObject. true means accounts with matching opportunities, false means accounts without. It is not an operator key, and it is not a relation key. We tried both of those before we found the real one, and the create call rejected each of them with a JSON parser error.
The nested criteria also need more than the usual column, operator, and value. In our testing, each criterion inside a cross filter needed an entityName and a filterType:
"crossFilters": [
{
"includesObject": false,
"primaryEntityField": "ACCOUNT_ID",
"relatedEntity": "Opportunity",
"relatedEntityJoinField": "AccountId",
"criteria": [
{
"column": "IsClosed",
"entityName": "Opportunity",
"filterType": "fieldValue",
"operator": "equals",
"value": "False"
}
]
}
]
Leave off entityName and filterType and the request fails, or worse, the criterion does not persist the way you meant it to.
Learn the Contract From the UI, Not From Memory
The fastest way we found to get any of this right was to build the report once by hand. Open the Report Builder, create the exact report you want your code to produce, save it, and then read it back with the Describe resource:
GET /services/data/v62.0/analytics/reports/{reportId}/describe
The describe response shows you the real reportMetadata Salesforce stored: the column tokens, the cross filter shape, the date filter, the scope. Copy that structure and your code is working from what the platform actually accepts, not from what the documentation implies or what seems reasonable. Every fix in this post came from comparing our payload to a describe of a hand-built report.
Four Defaults That Quietly Broke Things for Us
Even with valid columns and filters, a report can save successfully and still be wrong. These are the behaviors that caught us. The first three are what we observed in our build, not rules we found written down, so verify them in your own org.
Long report names failed. In our testing, the create call rejected names longer than 40 characters. If names come from user input or are generated, truncate them before you send.
Scope did not default to the whole org. The scope property decides whether a report runs against all records, records you own, or your team’s records, and the valid values depend on the report type. When we left it off, our reports ran against the running user’s own records. Our integration user did not own the data, so the reports ran and returned zero rows. Set scope to organization explicitly when you want the whole org’s records.
An unset date filter limited results to a recent window. When we did not set standardDateFilter, the saved report came back with a created-date filter covering a recent period. Any record older than that window simply disappeared from the results. Our demo data was a few months old, so our first successful reports were empty and looked broken. Always set the standard date filter explicitly to the range you actually want.
Relative dates go in the date filter, not a column filter. A request like “opportunities closing this quarter” is tempting to express as a column filter with a value like THIS_QUARTER. The API rejected that for us. Relative ranges belong in standardDateFilter, whose durationValue takes a date literal or CUSTOM:
"standardDateFilter": {
"column": "CLOSE_DATE",
"durationValue": "THIS_QUARTER"
}
Column filters are for literal values. Relative date logic belongs to the standard date filter.
If You Are Calling From Apex, Watch the Timeout Budget
If your report creation runs in Apex, remember that the maximum cumulative timeout for callouts in a single Apex transaction is 120 seconds, added up across every callout in that transaction. A describe call to resolve column tokens, a call to whatever builds the report definition, and the create call itself all spend from the same budget. We split our flow into separate transactions so a slow step could not starve the create call at the end. Plan for that before you find it in production.
Test Against the Live API, Not Just Fixtures
Here is the lesson we would pass along first. Our unit tests passed. Every one of them was green, and the live API rejected the payload anyway.
The reason is simple once you see it. We wrote the test fixtures by hand, based on our understanding of the contract. When that understanding was wrong, the fixtures encoded the same wrong shape, so the tests confirmed our mistake instead of catching it. Mock-based tests are still worth having, but for an external contract like this they only prove your code matches your assumptions.
So we made a live end-to-end check a required gate. Create the report against a real org, then read it back with the describe call and compare what Salesforce persisted to what you intended.
Pay attention to that read-back step. Do not verify by row count alone. In our case, “active accounts without an open opportunity” and “active accounts without any opportunity” happened to return the same number of rows on our demo data. A criterion could have been silently dropped and the count would have looked perfect. Checking the persisted criteria in the describe response is what actually proves the report means what you asked for.
Where This Leaves You
The Analytics REST API is a solid way to put real, native reports in front of users, and it beats exporting data into some other tool. It just rewards being precise about the contract. Resolve column tokens from the report type describe, use includesObject for cross filters, set scope and the date filter on purpose, and verify every create with a live read-back.
If you are planning an integration that builds reports or dashboards in your org, we are happy to talk through the approach before you write the first payload.