Many org-scoped API endpoints reject project slugs in the ?project= parameter
- Dominant language
- Python
- Stars
- 44.8k
- Forks
- 4.9k
- Avg merge
- 21h 10m
- Merged PRs (30d)
- 635
Description
## Problem
Sentry's public API identifies projects by their **slug** — the human-readable identifier that appears in URLs and is how customers reference projects (e.g. `org-slug/project-slug`, similar to GitHub's `owner/repo`). While numeric project IDs are also accepted and sometimes preferred for performance, the slug is the primary external-facing identifier we encourage customers to use.
Despite this, many organization-scoped endpoints only accept **numeric project IDs** in the `?project=` query parameter (and in some request body fields), returning HTTP 400 if a slug is passed. This forces API consumers to first resolve a slug to a numeric ID before calling these endpoints — an unnecessary extra round-trip and a poor developer experience.
A separate `?projectSlug=` query parameter exists on some endpoints, but it is not widely documented and is inconsistent with the `?project=` parameter that most tooling already uses.
Since project slugs **cannot be entirely numeric** (enforced by `no_numeric_validator`), disambiguating between an ID and a slug is trivial and already implemented in utilities like `IdOrSlugLookup`.
## Where the inconsistency comes from
The majority of org-scoped endpoints go through a shared code path — `get_requested_project_ids_unchecked()` in `src/sentry/api/bases/organization.py` — which hard-casts `?project=` values to `int`:
```python
def get_requested_project_ids_unchecked(self, request):
try:
return set(map(int, request.GET.getlist("project")))
except ValueError:
raise ParseError(detail="Invalid project parameter. Values must be numbers.")
```
This is called by `get_projects()`, the standard way `OrganizationEndpoint` subclasses resolve the `?project=` query parameter. Beyond this shared path, several other endpoints do their own direct numeric-only lookups.
## Proposed Solution
Update `get_requested_project_ids_unchecked()` and `get_projects()` to partition `?project=` values into IDs (all-digit values) and slugs (everything else), then resolve both. This single change would fix the majority of affected endpoints. A handful of endpoints that do their own project lookups outside of `get_projects()` need individual fixes.
Existing utilities that already support this pattern:
- **`IdOrSlugLookup`** (`src/sentry/db/models/fields/slug.py`) — ORM lookup that routes to `id` or `slug` based on `.isdecimal()`
- **`ProjectField(id_allowed=True)`** (`src/sentry/api/serializers/rest_framework/project.py`) — DRF field accepting both
## Affected Endpoints
### Endpoints using `get_projects()` (shared code path)
All of these inherit from `OrganizationEndpoint` and use the standard `?project=` query parameter flow. Fixing `get_projects()` fixes all of them.
**Events / Discover**
- `OrganizationEventsEndpoint` — `src/sentry/api/endpoints/organization_events.py` — GET
- `OrganizationEventsTimeseriesEndpoint` — `src/sentry/api/endpoints/organization_events_timeseries.py` — GET
**Issues**
- `OrganizationGroupIndexEndpoint` — `src/sentry/issues/endpoints/organization_group_index.py` — GET, PUT, DELETE
**Releases**
- `OrganizationReleaseDetailsEndpoint` — `src/sentry/releases/endpoints/organization_release_details.py` — GET, PUT, DELETE
- `ReleaseDeploysEndpoint` — `src/sentry/releases/endpoints/release_deploys.py` — GET, POST
- `OrganizationReleasesEndpoint` — `src/sentry/api/endpoints/organization_releases.py` — GET, POST
**Sessions**
- `OrganizationSessionsEndpoint` — `src/sentry/api/endpoints/organization_sessions.py` — GET
**Monitors / Crons**
- `OrganizationMonitorIndexEndpoint` — `src/sentry/monitors/endpoints/organization_monitor_index.py` — GET, POST
**Alerts / Detectors / Workflows**
- `OrganizationAlertRuleIndexEndpoint` — `src/sentry/incidents/endpoints/organization_alert_rule_index.py` — GET, POST
- `OrganizationDetectorIndexEndpoint` — `src/sentry/workflow_engine/endpoints/organization_detector_index.py` — GET, POST, PUT, DELETE
- `OrganizationWorkflowIndexEndpoint` — `src/sentry/workflow_engine/endpoints/organization_workflow_index.py` — GET, POST, PUT, DELETE
**Replays**
- `OrganizationReplayIndexEndpoint` — `src/sentry/replays/endpoints/organization_replay_index.py` — GET
- `OrganizationReplaySelectorIndexEndpoint` — `src/sentry/replays/endpoints/organization_replay_selector_index.py` — GET
- `OrganizationReplayCountEndpoint` — `src/sentry/replays/endpoints/organization_replay_count.py` — GET
**Stats**
- `OrganizationStatsEndpointV2` — `src/sentry/api/endpoints/organization_stats_v2.py` — GET
- `OrganizationStatsSummaryEndpoint` — `src/sentry/api/endpoints/organization_stats_summary.py` — GET
**Notification Actions**
- `NotificationActionsIndexEndpoint` — `src/sentry/notifications/api/endpoints/notification_actions_index.py` — GET, POST
**Other**
- `OrganizationProjectKeysEndpoint` — `src/sentry/api/endpoints/organization_project_keys.py` — GET
- `OrganizationDashboardsEndpoint` — `src/sentry/dashboards/endpoints/organization_dashboards.py` — GET
- `DiscoverSavedQueriesEndpoint` — `src/sentry/discover/endpoints/discover_saved_queries.py` — POST (`projects` body field)
### Endpoints with their own numeric-only project lookups (need individual fixes)
These bypass `get_projects()` and do direct ID-only lookups:
- `OrganizationDetectorIndexEndpoint` POST — `src/sentry/workflow_engine/endpoints/organization_detector_index.py` — `projectId` body param uses `to_valid_int_id()` → `Project.objects.get(id=...)`
- `OrganizationReleaseDetailsEndpoint` GET — `src/sentry/releases/endpoints/organization_release_details.py` — `project` query param uses `int()` → `Project.objects.get_from_cache(id=...)`
- `OrganizationCodeMappingsEndpoint` POST — `src/sentry/integrations/api/endpoints/organization_code_mappings.py` — `projectId` body param → `Project.objects.get(id=...)`
- `OrganizationCodeMappingDetailsEndpoint` PUT — `src/sentry/integrations/api/endpoints/organization_code_mapping_details.py` — `projectId` body param → `Project.objects.get(id=...)`
- `OrganizationStatsEndpoint` — `src/sentry/api/endpoints/organization_stats.py` — `projectID` query param → `Project.objects.filter(id__in=...)`
- `OrganizationCombinedRuleIndexEndpoint` — `src/sentry/incidents/endpoints/organization_alert_rule_index.py` — `project` query param → `get_requested_project_ids_unchecked()`
- `OrganizationOnDemandRuleStatsEndpoint` — `src/sentry/incidents/endpoints/organization_alert_rule_index.py` — `project_id` query param → `to_valid_int_id()`
- `PromptsActivityEndpoint` — `src/sentry/api/endpoints/prompts_activity.py` — `project_id` query/body → `Project.objects.filter(id=...)`
- `OrganizationEventsAnomaliesEndpoint` — `src/sentry/seer/endpoints/organization_events_anomalies.py` — `project_id` body → `to_valid_int_id()`
- `CustomRulesEndpoint` — `src/sentry/api/endpoints/custom_rules.py` — `project` query / `projects` body → `int()` cast
- `OrganizationEventsRootCauseAnalysisEndpoint` — `src/sentry/api/endpoints/organization_events_root_cause_analysis.py` — `project` query → passed directly to snuba
### Excluded (no organization context — cannot resolve slugs)
- `AdminRelayProjectConfigsEndpoint` — internal admin, no org context
- `SentryAppInstallationExternalRequestsEndpoint` — uses installation context, no org endpoint
Contributor guide
Research direction
Start with get_requested_project_ids_unchecked() and get_projects() in src/sentry/api/bases/organization.py, then inspect the listed endpoint files for direct numeric-only project lookups. Compare the existing IdOrSlugLookup and ProjectField(id_allowed=True) utilities, and verify that affected endpoints accept both project IDs and slugs while excluded endpoints remain unchanged.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- api, backend, backend-api-design
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 48/100