NAME
WWW::Authentik::API - authentik REST API v3 with idempotent ensure methods
VERSION
version 0.001
SYNOPSIS
my $api = WWW::Authentik->new( base_url => $url, token => $token )->api;
# one call, one endpoint
my $user = $api->find_user('alice');
my $new = $api->create_group( { name => 'staff' } );
# wanted state, as often as you like
my $r = $api->ensure_user( username => 'alice', name => 'Alice', group_names => ['staff'] );
print $r->{changed}; # 'created', 'updated' or ''
print $r->{object}{pk};
DESCRIPTION
authentik's REST API v3 for the whole instance. There is no realm; what a call reaches is decided by the API token.
The basic methods are one endpoint each. get_* and find_* return the representation as a hash (find_* returns nothing when there is no match), list_* an array reference with every match, create_* and update_* the representation authentik answered with, and delete_* true.
A list_* walks authentik's pagination for you, asking for "page_size" objects at a time and following pagination.next until it is 0. It stops when a page comes round a second time, so an answer whose next points backwards ends the walk instead of running for ever.
Writing is PATCH. authentik's PUT wants the required fields but leaves everything else alone, so it replaces nothing that PATCH would not; there is no reason to use it.
Every failure is a WWW::Authentik::Error::API. Note that a duplicate is 400 with a field error, not 409, and that a refused token is 403, not 401.
The ensure_* methods are what makes a setup repeatable. Each looks the object up by its readable key, creates it when it is missing, otherwise writes only what differs, and returns { object => \%rep, changed => 'created' | 'updated' | '' }. Only the keys given are compared; nothing is ever deleted.
Where authentik wants an identifier and a person knows a name, "resolve" looks it up. Which form counts as the identifier is fixed per field and never guessed; see there.
base_url
Required. The authentik URL without /api/v3.
token
Required. The API token, sent as a bearer token with every call.
ua
Required. The LWP::UserAgent to use.
page_size
How many objects a list_* asks for per request. Default 100.
uuid_pattern
integer_pattern
The two shapes "resolve" takes for an identifier rather than a name: a UUID and a run of digits. Net::Async::Authentik::API uses these so the distinction is made in one place.
diff_class
The class the ensure_* methods compare with, WWW::Authentik::Diff. Net::Async::Authentik uses the same one.
api_url
print $api->api_url; # https://id.example.org/api/v3
call
my $result = $api->call( GET => '/core/users/?username=alice' );
my $result = $api->call( POST => '/core/groups/', { name => 'staff' } );
One request against the API. Returns what "send_request" in WWW::Authentik::Role::HTTP returns. The way to reach an endpoint this class has no method for. Mind the trailing slash: authentik answers a path without one with 404, and this method adds nothing.
version
print $api->version->{version_current}; # 2026.8.3
config
The instance's public configuration.
settings
The instance settings, among them default_token_duration, which decides how long a new token lives.
me
Who the API token belongs to.
list_users
my $users = $api->list_users( type => 'internal' );
find_user
my $user = $api->find_user('alice') or die 'no such user';
By username, exactly and case-sensitively: authentik lets Alice and alice exist side by side.
get_user
create_user
my $user = $api->create_user( { username => 'alice', name => 'Alice' } );
update_user
delete_user
set_password
$api->set_password( $user->{pk}, $password );
authentik accepts any password here; a password policy only applies inside a flow.
create_service_account
my $account = $api->create_service_account( name => 'provisioner' );
print $account->{token}; # handed out once, and only here
Creates a user of type service_account together with an app-password token. That token is the password for "client_credentials_token" in WWW::Authentik::OIDC with a username.
list_authenticators
my $devices = $api->list_authenticators( $user->{pk} );
The authenticator devices of a user, as a plain list. Creating a TOTP device over the API is not possible: POST /authenticators/admin/totp/ answers 500 in authentik 2026.8.3. Enrolment goes through the TOTP setup flow.
list_groups
find_group
my $group = $api->find_group('staff');
get_group
create_group
update_group
delete_group
add_user_to_group
$api->add_user_to_group( $group->{pk}, $user->{pk} );
remove_user_from_group
Removing someone who is not a member answers 204 as well.
list_tokens
find_token
my $token = $api->find_token('provisioner');
By identifier, which is also the address of the detail endpoint.
get_token
create_token
my $token = $api->create_token( { identifier => 'provisioner', intent => 'api', expiring => \0 } );
expires cannot be chosen: authentik ignores it and sets the expiry from the instance setting default_token_duration. expiring => \0 is how a token is made to live for ever.
update_token
delete_token
view_token_key
my $key = $api->view_token_key('provisioner')->{key};
set_token_key
$api->set_token_key( 'provisioner', $key );
Puts a key of your choosing on a token, so a setup can know it without reading it back.
list_applications
find_application
my $app = $api->find_application('my-app');
By slug, which is the address of the detail endpoint. Returns nothing when there is none.
create_application
my $app = $api->create_application( { name => 'My App', slug => 'my-app', provider => $provider->{pk} } );
An application holds at most one provider, and a provider belongs to at most one application.
update_application
delete_application
check_access
my $access = $api->check_access('my-app');
my $access = $api->check_access( 'my-app', for_user => $user->{pk} );
Whether the application's policies let someone in: { passing => 1, messages => [], log_messages => [] }.
Without for_user the answer is about the user the API token belongs to. A for_user that no user has is a plain field error (400 {"for_user": "User not found"}), so a stale id cannot pass for an answer — with one exception worth knowing: for_user => 1 is accepted on 2026.8.3 because primary key 1 is authentik's internal AnonymousUser, which "get_user" and "list_users" both deny the existence of.
list_oauth2_providers
find_oauth2_provider
my $provider = $api->find_oauth2_provider('my-app');
By name, which is unique.
get_oauth2_provider
create_oauth2_provider
my $provider = $api->create_oauth2_provider( {
name => 'my-app',
authorization_flow => $flow->{pk},
invalidation_flow => $other->{pk},
redirect_uris => [ { matching_mode => 'strict', url => 'https://app.example.org/cb' } ],
grant_types => [qw( authorization_code refresh_token )],
} );
name, authorization_flow, invalidation_flow and redirect_uris are required. Without grant_types authentik stores an empty list, and such a provider answers every token request with invalid_grant; "ensure_oauth2_provider" refuses to create one.
update_oauth2_provider
delete_oauth2_provider
provider_setup_urls
my $urls = $api->provider_setup_urls( $provider->{pk} );
The OpenID endpoints of the provider. issuer, provider_info, jwks and logout are undef until the provider belongs to an application.
preview_user
my $preview = $api->preview_user( $provider->{pk}, $user->{pk} );
What a token for this user would contain, without issuing one.
list_scope_mappings
find_scope_mapping
my $mapping = $api->find_scope_mapping("authentik default OAuth Mapping: OpenID 'email'");
By name, which is unique. scope_name is not: several mappings may offer the same scope.
find_scope_mappings_by_scope
my $mappings = $api->find_scope_mappings_by_scope(qw( openid email profile ));
One mapping per scope name, in the order the names were given. A scope without a mapping is a validation error naming it. When several mappings offer the same scope the first authentik lists wins.
get_scope_mapping
create_scope_mapping
my $mapping = $api->create_scope_mapping( { name => 'amr', scope_name => 'amr',
expression => 'return {"amr": request.context.get("amr", [])}' } );
name, scope_name and expression are required, and the expression is compiled when it arrives: a syntax error comes back as a field error on expression.
update_scope_mapping
delete_scope_mapping
test_property_mapping
my $result = $api->test_property_mapping( $mapping->{pk}, user => $user->{pk} );
list_flows
find_flow
my $flow = $api->find_flow('default-authentication-flow');
By slug. The pk in the answer is the UUID everything else refers to.
create_flow
my $flow = $api->create_flow( { name => 'Probe', slug => 'probe', title => 'Probe',
designation => 'authentication' } );
update_flow
delete_flow
export_flow
my $yaml = $api->export_flow('default-authentication-flow');
The flow as a blueprint. authentik answers YAML with a text/html content type, so this returns the body as a string, not a structure.
stage_types
All 25 stage types authentik offers, each with its component and model_name.
list_stages
my $stages = $api->list_stages;
Every stage of every type, in the reduced representation /stages/all/ gives out: pk, name, component and meta_model_name, without the fields of its type. Use "get_stage" with the type for the whole thing.
find_stage
my $stage = $api->find_stage('default-authentication-mfa-validation');
By name, which is unique across all stage types.
get_stage
my $stage = $api->get_stage( 'authenticator/validate', $uuid );
$type is the path under /stages/: password, identification, user_login, authenticator/validate, authenticator/totp, consent and so on. all is refused, because that endpoint can only be read.
create_stage
my $stage = $api->create_stage( password => { name => 'probe-password',
backends => ['authentik.core.auth.InbuiltBackend'] } );
update_stage
delete_stage
Deleting a stage deletes the bindings that point at it.
list_bindings
my $bindings = $api->list_bindings( flow => 'my-flow' );
my $bindings = $api->list_bindings( target => $flow->{pk} );
flow takes a slug or a UUID and is turned into target. target itself goes through as it is, and authentik answers a slug there with a field error.
get_binding
create_binding
my $binding = $api->create_binding( { target => $flow->{pk}, stage => $stage->{pk}, order => 20 } );
target, stage and order together are unique.
update_binding
delete_binding
list_brands
current_brand
my $brand = $api->current_brand;
The brand that applies to this request, as flow_device_code and the other flows it names — what a device flow needs for a person to be able to approve it.
This is the public view, and it cannot be written back. It has neither brand_uuid nor domain, so there is nothing to hand "update_brand". To change a brand, take it from "list_brands":
my ( $brand ) = grep { $_->{default} } @{ $api->list_brands };
$api->update_brand( $brand->{brand_uuid}, { flow_device_code => $flow->{pk} } );
update_brand
$api->update_brand( $brand->{brand_uuid}, { flow_device_code => $flow->{pk} } );
A brand is addressed by brand_uuid, not by pk.
list_certificates
find_certificate
my $cert = $api->find_certificate('authentik Self-signed Certificate');
list_blueprints
get_blueprint
create_blueprint
my $blueprint = $api->create_blueprint( { name => 'probe', content => $yaml } );
The YAML is validated when it arrives; an unknown model is a field error on content.
apply_blueprint
$api->apply_blueprint( $blueprint->{pk} );
Asks authentik to apply it. The worker does that in the background, so the answer still says status => 'unknown'; read the blueprint again a moment later to see successful.
delete_blueprint
resolvable_fields
The table "resolve" works from: field name to the identifier form, the key that forces a lookup, and the method that does it.
resolve
my $rep = $api->resolve( { authorization_flow => 'default-authentication-flow' } );
# { authorization_flow => '57b03d1d-...' }
authentik wants identifiers where a person knows a name. This turns the names into identifiers, and it never guesses: for every field, one shape counts as the identifier and everything else is a name.
field identifier is forced lookup
--------------------- ---------------------- ---------------------------
provider an integer provider_name
user an integer user_name
authorization_flow a UUID authorization_flow_slug
invalidation_flow a UUID invalidation_flow_slug
authentication_flow a UUID authentication_flow_slug
configure_flow a UUID configure_flow_slug
flow_device_code a UUID flow_device_code_slug
signing_key a UUID signing_key_name
encryption_key a UUID encryption_key_name
groups UUIDs group_names
property_mappings UUIDs property_mapping_names
configuration_stages UUIDs configuration_stage_names
So a provider named 123 cannot be reached through provider, because provider => '123' is the provider with the primary key 123. Write provider_name => '123'; the forced form never looks at the shape of the value. The same goes for a flow whose slug happens to look like a UUID. Giving both forms of one field is a validation error, and so is a name nothing matches.
scopes is the one field with no counterpart in authentik: a list of scope names that becomes property_mappings. Giving scopes together with property_mappings is a validation error.
ensure_user
my $r = $api->ensure_user( username => 'alice', name => 'Alice',
email => 'alice@example.org', password => $pw, group_names => ['staff'] );
Found by username. password is not a field of the user: it is set once, right after the user is created, and never touched again, so a setup that runs twice does not reset it. Call "set_password" to change one.
ensure_group
Found by name.
ensure_token
my $r = $api->ensure_token( identifier => 'provisioner', intent => 'api',
expiring => \0, user_name => 'akadmin', key => $key );
Found by identifier. key is like password: put on the token when it is created, never after. expires is refused, because authentik ignores it.
ensure_application
my $r = $api->ensure_application( slug => 'my-app', name => 'My App', provider_name => 'my-app' );
Found by slug.
ensure_oauth2_provider
my $r = $api->ensure_oauth2_provider(
name => 'my-app',
authorization_flow_slug => 'default-provider-authorization-implicit-consent',
invalidation_flow_slug => 'default-provider-invalidation-flow',
client_type => 'confidential',
grant_types => [qw( authorization_code refresh_token )],
redirect_uris => [ { matching_mode => 'strict', url => 'https://app.example.org/cb' } ],
scopes => [qw( openid email profile )],
signing_key_name => 'authentik Self-signed Certificate',
);
Found by name. Creating one without a non-empty grant_types is refused: authentik would store an empty list and the provider would answer every token request with invalid_grant. Updating an existing provider does not need them.
redirect_uris may be written without redirect_uri_type; authentik adds it, and the comparison knows that. property_mappings come back in authentik's own order, and the comparison treats them as a set, so a second run reports no change.
ensure_scope_mapping
Found by name.
ensure_flow
Found by slug.
ensure_stage
my $r = $api->ensure_stage( 'authenticator/validate',
name => 'default-authentication-mfa-validation',
not_configured_action => 'deny',
);
Found by name, which is unique across all stage types; read, created and written through the typed endpoint. Note that not_configured_action => 'configure' needs configuration_stages in the same write, which happens by itself here because every differing key goes out in one PATCH.
ensure_binding
my $r = $api->ensure_binding( flow => 'probe-flow', stage => 'probe-password', order => 20 );
flow takes a slug or a UUID, stage a name or a UUID. A flow binds a given stage once: a second call with another order moves the binding instead of adding one. Bind a stage twice with "create_binding".
SUPPORT
Issues
Please report bugs and feature requests on GitHub at https://github.com/Getty/p5-www-authentik/issues.
IRC
Join #kubernetes on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <getty@cpan.org>
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.