Drupal Api Endpoints
Published by grasmash in drupal-claude-skills
What this skill does
Patterns for custom Drupal API endpoints that use UUIDs instead of sequential IDs: route, controller, and fetch templates, response-shape contracts for installed app clients, webhook handlers, authenticated cURL testing, and the JSON:API filter-access cache-poisoning fix. Use when creating custom routes, AJAX or JSON endpoints for a frontend or mobile app, webhook receivers, or JSON:API filters, and before adding an endpoint, to check whether an existing one already serves the need.
Add Drupal Api Endpoints to your agent
Review the source and files first. When you are ready, copy the prompt instruction or use the CLI command supported by your environment.
Install with a prompt
Paste this into a compatible coding agent:
add this skill "drupal-api-endpoints" from https://github.com/grasmash/drupal-claude-skillsInstall with the CLI
Run this command in a controlled environment after reviewing the repository:
npx skills add https://github.com/grasmash/drupal-claude-skills --skill drupal-api-endpointsSkill instructions
Drupal API Endpoint Patterns
Critical Rules
ALWAYS follow these rules for ALL endpoints:
- ALWAYS use UUIDs - never expose entity IDs.
- ALWAYS check for existing endpoints before creating a new one.
- NEVER use the core REST module - use custom routes.
- ALWAYS use JSON for request/response.
- ALWAYS implement access controls and caching.
- Place routes in the correct module - the module that owns the functionality.
- AVOID
/api/*paths for custom routes - reserved for JSON:API.
URL Pattern Convention
Preferred pattern: /MODULE-api/resource/action
Examples
✅ /achievements-api/unlock # my_achievements module
✅ /scoring-api/ingest # my_scoring module
❌ /api/custom-endpoint # Conflicts with JSON:API
Check for Existing Endpoints
ALWAYS search before creating:
# Search all custom module routes
grep -r "path: '/" web/modules/custom/*/routing.yml
# Search for API patterns
grep -r "path: '/.*-api" web/modules/custom/
# Check by pattern
ddev drush ev "print_r(\Drupal::service('router.route_provider')->getRoutesByPattern('/scoring-api')->all());"
Creating API Endpoints
Step 1: Define Route (MODULE.routing.yml)
my_module.api.action_name:
path: '/my-module-api/entity-type/{uuid}/action'
defaults:
_controller: '\Drupal\my_module\Controller\ApiController::actionName'
methods: [POST]
requirements:
_user_is_logged_in: 'TRUE' # For authenticated
# OR for public: _access: 'TRUE'
_format: 'json'
options:
parameters:
uuid:
type: 'string'
no_cache: 'TRUE' # Disable caching if needed
Step 2: Implement Controller
<?php
declare(strict_types=1);
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Access\AccessResult;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
final class ApiController extends ControllerBase {
/**
* Check access for the endpoint.
*/
public function access($uuid) {
// Load entity by UUID
$entities = \Drupal::entityTypeManager()
->getStorage('entity_type')
->loadByProperties(['uuid' => $uuid]);
if (empty($entities)) {
return AccessResult::forbidden('Entity not found');
}
$entity = reset($entities);
// Check permissions
return AccessResult::allowedIf(
$entity->access('update') &&
\Drupal::currentUser()->hasPermission('permission_name')
)->addCacheableDependency($entity);
}
public function actionName(Request $request, $uuid) {
try {
// Parse JSON payload
$payload = json_decode($request->getContent(), TRUE);
// Validate payload
if (!$payload || !isset($payload['required_field'])) {
return new JsonResponse([
'success' => FALSE,
'error' => 'Invalid payload'
], 400);
}
// Load entity by UUID
$entities = \Drupal::entityTypeManager()
->getStorage('entity_type')
->loadByProperties(['uuid' => $uuid]);
if (empty($entities)) {
return new JsonResponse([
'success' => FALSE,
'error' => 'Entity not found'
], 404);
}
$entity = reset($entities);
// Perform action
// ... business logic ...
// Clear caches
\Drupal::service('cache_tags.invalidator')
->invalidateTags($entity->getCacheTags());
return new JsonResponse([
'success' => TRUE,
'data' => [
'uuid' => $entity->uuid(),
'status' => 'completed'
]
]);
} catch (\Exception $e) {
\Drupal::logger('my_module')->error(
'API error: @message',
['@message' => $e->getMessage()]
);
return new JsonResponse([
'success' => FALSE,
'error' => 'An error occurred'
], 500);
}
}
}
Step 3: Client Implementation (fetch)
This skill covers the Drupal end of the seam: auth, CSRF tokens, and response shape. If the client is built with a JavaScript framework (React, Vue, Angular, etc.), also load a skill for that framework and follow its current practices, including escaping and XSS guidance when rendering API output.
Use fetch with optimistic UI and revert-on-failure — not jQuery $.ajax.
try {
const response = await fetch(`/my-module-api/entity-type/${entityUuid}/action`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, // from /session/token
},
body: JSON.stringify({ required_field: value }),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error);
}
} catch (err) {
// revert optimistic UI change; show error via your project's message helper
}
Common Patterns
Load by UUID (required for all endpoints)
$entities = \Drupal::entityTypeManager()
->getStorage('entity_type')
->loadByProperties(['uuid' => $uuid]);
$entity = !empty($entities) ? reset($entities) : NULL;
Multiple Permission Check
$access = AccessResult::allowedIf(
$user->hasPermission('permission_1') ||
$user->hasPermission('permission_2')
)->cachePerPermissions();
Cache Metadata
Only a CacheableResponseInterface response carries cache metadata (a plain
JsonResponse has no getCacheableMetadata() and is never cached by Dynamic
Page Cache). For a cacheable GET, return core's CacheableJsonResponse:
use Drupal\Core\Cache\CacheableJsonResponse;
$response = new CacheableJsonResponse($data);
$response->getCacheableMetadata()
->addCacheContexts(['user', 'user.permissions']) // drop 'user' only if the data is not per-user
->addCacheTags($entity->getCacheTags());
Contexts, tags, invalidation and the freshness test a caching change owes:
see the drupal-performance skill (references/caching-lessons.md §1, §2, §7).
Response-Shape Contracts (installed clients)
When an endpoint is consumed by an installed mobile/desktop app, the fleet is never on one version — often the largest share runs an old build. "The app handles it" describes the build on your phone, not the response you changed.
- Adding keys is safe. Removing, renaming, or nulling a key is a contract break for any build that reads it.
- A new branch of an existing handler must carry every key the other
branches carry. The classic break: a new early-return branch (e.g. "nothing
available") omits a string field, and an old build that calls a method on it
(
value.replace(...)) crashes outright. - Record what each supported build reads. Per route, list the fields each still-supported client version reads, and mark the ones it will crash without (it calls a method on the value). Derive it from each version's shipping source, not the app repo tip.
- Test every response branch of the handler against that record — drive each branch through the real controller and assert the required fields are present and non-null. A route with required reads but no driven branch should fail the test, not pass silently.
- To retire a field, keep it until the builds that require it fall out of support, or record the break explicitly as an accepted gap.
Webhook Handlers
Providers retry and can deliver the same event concurrently, and another code path (e.g. a post-purchase redirect) may race the webhook for the same object.
$lock = \Drupal::lock();
$lock_name = 'my_module_subscription:' . $remote_id;
if (!$lock->acquire($lock_name, 30)) {
// Another process is handling this object; log and exit.
return;
}
try {
// Idempotent upsert: load by remote id, update if found, create if not.
}
finally {
$lock->release($lock_name);
}
- Lock per object, not globally, keyed by type + remote id.
- Upsert idempotently — a redelivered event updates the existing record and treats "already processed" as success, never creates a duplicate.
- User auto-creation must be race-safe: wrap
save()in try/catch; on failure, immediately load the user by email and return the existing account instead of failing. - Log every attempt; catch and log exceptions rather than letting them escape (an escaped exception typically makes the provider retry).
Debugging
Check Watchdog Logs
# Error logs only
ddev drush watchdog:show --severity=3 --count=10
# Check specific module
ddev drush watchdog:show --type=my_module
Test Endpoint
# GET request
curl -H "Content-Type: application/json" https://<project>.ddev.site/my-module-api/test
# POST with JSON
curl -X POST -H "Content-Type: application/json" \
-d '{"field":"value"}' \
https://<project>.ddev.site/my-module-api/entity/UUID/action
Apply to Files
web/modules/custom/my_module/routing.ymlweb/modules/custom/my_module/src/Controller/*Controller.php
Testing with cURL
For testing API endpoints with authenticated requests, see the comprehensive guide:
- @references/authenticated-curl-examples.md
Quick authenticated GET example:
# Get session with drush, then use cookie
curl -b /tmp/cookies.txt \
-H "Content-Type: application/json" \
https://<project>.ddev.site/my-module-api/status
JSON:API Filter Access (Critical)
When exposing entities via JSON:API with filtering, you MUST implement
hook_jsonapi_ENTITY_TYPE_filter_access(). Without it, Drupal's TemporaryQueryGuard
adds WHERE 1 = 0 to queries, causing cache poisoning.
Quick Fix Pattern
/**
* Implements hook_jsonapi_ENTITY_TYPE_filter_access() for my_entity.
*/
function mymodule_jsonapi_my_entity_filter_access(EntityTypeInterface $entity_type, AccountInterface $account) {
if ($account->isAuthenticated()) {
return [
JSONAPI_FILTER_AMONG_ALL => AccessResult::allowed()
->cachePerPermissions()
->addCacheContexts(['user.roles:authenticated']),
];
}
return [
JSONAPI_FILTER_AMONG_ALL => AccessResult::forbidden()->cachePerPermissions(),
];
}
See: @references/jsonapi-filter-access.md for full documentation on JSON:API cache poisoning prevention.
References
- @references/authenticated-curl-examples.md
- @references/jsonapi-filter-access.md
Files included
- references/authenticated-curl-examples.md
- references/jsonapi-filter-access.md
- SKILL.md

