Exactly. The `CreatedBy` filter has a significant failure mode if your testing period spanned a permission set or profile change for that user. The underlying `User.Id` remains constant, but the effective ownership context can diverge.
A more durable approach is to join against `User` in your SOQL and filter on the test user's `Username` or a custom flag on the `User` record itself. This survives profile reassignments. But as you noted, it still fails if records were manually reassigned via transfer rules or a data loader script changing the `OwnerId` field, which is distinct from `CreatedById`.
This is why a multi-pass heuristic is often necessary: combine `CreatedBy`, a date range from your testing sprint, and perhaps a sentinel value in a unused custom field you can patch en masse before deletion.
Excellent expansion on the multi-pass heuristic. The sentinel field strategy is particularly effective, but it introduces a pre-deployment dependency. You must verify the target custom field is truly unused across all profiles and page layouts, including any managed packages. I've seen a 'Description' field reused for internal notes, which then got purged.
This approach also assumes you have the administrative bandwidth to execute a two-step operation: a mass update to tag the records, then the actual delete. For some teams, that's two change control windows instead of one.
Check the SLA.
Several posts have correctly identified the Bulk API and selective filtering as the technical path. Since you're concerned about platform terms, your primary verification step should be the API Daily Limits page in your Setup menu and the Bulk API Developer Guide. Operating within those published limits is, by definition, sanctioned.
However, the critical nuance for your specific case is the multi-object dependency. You mention "profiles, dummy campaign records, and form submission entries." You cannot delete these in arbitrary order due to referential integrity. Form submissions may be children of campaigns or profiles. You'll need to script the deletion in the correct sequence, starting with the leaf objects (like individual form entries), then working up to parent records (campaigns), and finally the test profiles. A single bulk job that tries to delete across objects without this order will fail with foreign key constraint errors.
You're spot on about the deletion order. I learned this the hard way with a self-hosted system.
The "sanctioned" part using daily limits is a solid point too. Makes me wonder, though: do those API limits reset at midnight based on the platform's timezone or your own? That could make a difference if you're trying to queue up jobs late in the day.
Self-host or die trying.
That limit reset timezone is a sneaky one, because they don't *explicitly* tell you. It's based on the instance's locale, not your local time. Found this out doing a big batch job at 11:30 PM my time, only to have it fail because the instance was already in the next day UTC+something.
Your safest bet is to check the timestamps on your API limit usage in Setup. They'll clue you in to the reset schedule. For scheduling jobs, I'd assume UTC and add a buffer.
The hard way is a great teacher, huh? My lesson was trying to delete custom object records before clearing the related list from the parent. Classic "oh right, of course" moment at 3 AM.
NightOps
The caution about the UI's feasibility is well founded, but I'd push back slightly on the premise that it's categorically impossible. For a few thousand records, a well-constructed list view with an actionable filter and using the inline edit checkbox for mass selection can be viable, albeit tedious. The real bottleneck is the limit of 200 records per list view page; you're looking at dozens of page cycles.
However, your instinct to avoid it is correct because the error margin is too high. A single mis-click can propagate changes or deletions unpredictably. The mental load of maintaining a consistent selection pattern across that many pages introduces fatigue-based mistakes. This solidifies the case for an automated, auditable method like the Bulk API, even for this scale.
The other consideration is time. If your volume is at the high end of "several thousand," you're comparing an afternoon of careful scripting against potentially days of manual, error-prone work. The manual approach often becomes a hidden cost that outweighs the perceived risk of using the API.
Data over dogma
You've got the right instinct avoiding the manual UI, but you're still being too polite. The "official path" requiring a paid add-on for a basic cleanup is Granola's classic upsell trap. It's not overkill, it's a shakedown.
You're already paying for the platform. Their own Bulk API is the sanctioned way. The "terms of service" scare is just to push you toward the add-on. So long as you stay under the documented API limits, you're fine.
The real problem isn't the how, it's the order. Delete form submissions first, then campaigns, then profiles. Mess that up and you'll hit foreign key errors, leaving orphaned data that's even harder to find later. Write a simple script, run it in the dev console, and be done.
CRM is a necessary evil
Shakedown is a bit strong. They're just selling a solution to a problem they created. Classic.
But telling someone to just "write a simple script" glosses over the real cost. You're paying for the developer's time to write, test, and schedule that script safely. That's not free. The add-on's price tag might be less than a few hours of dev work. Sometimes the "trap" is actually the cheaper option.
Manual deletion for several thousand records isn't just time-consuming, it's error-prone. You'll hit the 200-record page limit, and the repetitive action guarantees a slip-up.
Focus your effort on identifying the records correctly, not deleting them manually. Your biggest risk isn't the API, it's bad filters that delete live data. Use a date range from your testing phase and, if possible, a test user's exact Username. Validate with a SELECT count before any DELETE.
Once your query is solid, use the Bulk API in the correct order: form submissions, then campaigns, then profiles.
Prove it with a benchmark.
You're right to dismiss the UI for several thousand records. The mental fatigue from clicking through dozens of 200-record pages will almost guarantee a mistake, and the platform's UI just isn't built for that volume of precise, repetitive action.
Focusing your effort on building the correct, safe query is the real work. Since you were testing real-world integrations, can you isolate those test records by the user who created them? Filtering by the specific test user's "CreatedById" might be the cleanest way to separate the data, as it's less likely to overlap with any real data that came in organically during your trial.
Once your query is airtight, the Bulk API path others have outlined is indeed the sanctioned way. Just remember to respect the object dependency order they mentioned; form submissions first, then campaigns, then profiles. That's the part you can't afford to get wrong.
Architect first, buy later
Good point about the failure modes. The custom flag on the User record is the safest anchor if you can implement it, but you need admin rights. For most people doing a one-time cleanup, that's not an option.
Your multi-pass idea is key. Just adding a date range to your CreatedBy filter is often enough to isolate the test block. It catches most of the data, and you can manually review the edge cases.
The real risk is assuming a single filter is perfect. It's not. You need the combination.
Exactly. The combination filter is the only safe way. But user773 misses the bigger cost: "manually review the edge cases." That's the hidden labor tax.
You think you'll catch a dozen strays. But if your filter logic was off, you might be reviewing hundreds of orphaned records. Now you're back in the UI, scrolling and clicking, which is what you wanted to avoid. The add-on isn't just about deletion, it's about reliable identification. If their tool can't do that either, then the whole product is a joke.
Show me the data
>The half-day project cost is real.
This is the only part that matters. If a senior dev spends four hours scripting, that's a significant cost. The add-on price is a known quantity. You're weighing a known cost against variable, often underestimated, internal time.
The gamble is assuming your script works perfectly on the first pass. Factor in debugging dependency errors and the second run after your limit resets. That half-day easily becomes a full day.
Your fancy demo doesn't scale.
>what are the practical, sanctioned ways to handle bulk deletion without this specific add-on?
The practical, sanctioned way is the Bulk API. It's built for this exact job. Your account rep has an incentive to sell the add-on, but the API exists for platform-level operations.
Your real focus should be on the query filters, not the deletion method itself. You need an air-tight combination filter to isolate that test data. Use the specific test user's ID, plus a date range covering your evaluation period. Run a count query first, and maybe even export those results to a CSV for a quick visual check before you delete anything.
Get that query right, and a simple script using the API will be a one-time task.
Automate the boring stuff.
>what are the practical, sanctioned ways
You've already listed the Bulk API, which is the answer. The sanctioned path is using the platform's own automation tools for a platform-level task. The core of your problem, as several have pointed out, is data isolation, not deletion mechanics.
You're worried about violating terms, but using the documented API within its published rate limits is the opposite of a workaround; it's the intended use. The risk is in your query logic, not the method. If you can't build a perfect filter combination (CreatedById + date range + maybe a custom test flag field), then the cost of a scripting mistake *is* the real budget question, not the add-on's price.
Show me the benchmarks