AccessGrid is an Elixir SDK for interacting with the AccessGrid.com API. This SDK provides a simple interface for managing NFC key cards, card templates, landing pages, credential profiles, webhooks, HID Origo organizations, and ledger items. Full docs at https://www.accessgrid.com/docs.
- Installation
- Configuration
- Quick Start
- API Reference
- Feature Matrix
- Utilities
- Error Handling
- Testing
- Security
- Contributing
- Development
- License
Add accessgrid to your list of dependencies in mix.exs:
def deps do
[
{:accessgrid, "~> 0.2.0"}
]
endThe SDK reads credentials from your application config. Add them in config/runtime.exs so environment variables are picked up at boot:
# config/runtime.exs
import Config
config :accessgrid,
account_id: System.get_env("ACCESSGRID_ACCOUNT_ID"),
api_secret: System.get_env("ACCESSGRID_API_SECRET")For static values (set at compile time) use config/config.exs instead. Either file works — the SDK doesn't care where the config came from.
With credentials in config, every SDK call resolves them automatically:
{:ok, card} = AccessGrid.AccessPasses.get("card_id")This is the default path for single-tenant apps.
For multi-tenant scenarios, testing, or scripted operations against multiple accounts, pass credentials explicitly via AccessGrid.Client.new/1:
client = AccessGrid.Client.new(
account_id: "your_account_id",
api_secret: "your_api_secret"
)
{:ok, card} = AccessGrid.AccessPasses.get("card_id", client: client)Every SDK function accepts client: as an option; when omitted, it falls back to the project config.
# Issue a new card
{:ok, card} = AccessGrid.AccessPasses.issue(%{
card_template_id: "template_id",
full_name: "Employee Name",
email: "employee@company.com"
})
IO.puts("Install URL: #{card.install_url}"){:ok, card} = AccessGrid.AccessPasses.issue(%{
card_template_id: "template_id",
employee_id: "123456789",
card_number: "16187",
site_code: "100",
full_name: "Employee Name",
email: "employee@yourwebsite.com",
phone_number: "+19547212241",
classification: "full_time",
start_date: "2025-01-31T22:46:25.601Z",
expiration_date: "2025-04-30T22:46:25.601Z",
employee_photo: AccessGrid.Utils.base64_file!("path/to/photo.png"),
metadata: %{department: "Engineering"}
})
IO.puts("Install URL: #{card.install_url}"){:ok, card} = AccessGrid.AccessPasses.get("card_id")
IO.puts("Card ID: #{card.id}")
IO.puts("State: #{card.state}")
IO.puts("Full Name: #{card.full_name}")
IO.puts("Install URL: #{card.install_url}")
IO.puts("Expiration Date: #{card.expiration_date}")
IO.puts("Card Number: #{card.card_number}")
IO.puts("Site Code: #{card.site_code}")
IO.puts("Devices: #{length(card.devices)}")
IO.puts("Metadata: #{inspect(card.metadata)}"){:ok, card} = AccessGrid.AccessPasses.update("card_id", %{
employee_id: "987654321",
full_name: "Updated Employee Name",
classification: "contractor",
expiration_date: "2025-02-22T21:04:03.664Z"
})# List all cards for a template
{:ok, cards} = AccessGrid.AccessPasses.list("template_id")
# List cards filtered by state
{:ok, active_cards} = AccessGrid.AccessPasses.list("template_id", state: "active")# Suspend a card
{:ok, card} = AccessGrid.AccessPasses.suspend("card_id")
# Resume a card
{:ok, card} = AccessGrid.AccessPasses.resume("card_id")
# Unlink a card
{:ok, card} = AccessGrid.AccessPasses.unlink("card_id")
# Delete a card
{:ok, card} = AccessGrid.AccessPasses.delete("card_id")All template fields are flat — no design: or support_info: wrappers. Pair image params with AccessGrid.Utils.base64_file!/1.
{:ok, result} = AccessGrid.Console.create_template(%{
name: "Employee NFC key",
platform: "apple",
use_case: "corporate_id",
protocol: "desfire",
allow_on_multiple_devices: true,
watch_count: 2,
iphone_count: 3,
background_color: "#FFFFFF",
label_color: "#000000",
label_secondary_color: "#333333",
background: AccessGrid.Utils.base64_file!("path/to/background.png"),
logo: AccessGrid.Utils.base64_file!("path/to/logo.png"),
icon: AccessGrid.Utils.base64_file!("path/to/icon.png"),
support_url: "https://help.yourcompany.com",
support_phone_number: "+1-555-123-4567",
support_email: "support@yourcompany.com",
privacy_policy_url: "https://yourcompany.com/privacy",
terms_and_conditions_url: "https://yourcompany.com/terms",
credential_profiles: ["cp_ex_id_1"],
landing_pages: ["lp_ex_id_1"],
metadata: %{version: "1.0"}
})
IO.puts("Template ID: #{result.id}")
IO.puts("Estimated Publishing: #{result.estimated_publishing_date}"){:ok, result} = AccessGrid.Console.update_template("template_id", %{
name: "Updated Employee NFC key",
watch_count: 3,
support_url: "https://help.yourcompany.com",
support_email: "newsupport@yourcompany.com"
})The same endpoint serves both single templates and template pairs. Pattern match on the returned struct to tell them apart.
case AccessGrid.Console.read_template("template_id") do
{:ok, %AccessGrid.CardTemplate{} = template} ->
IO.puts("Name: #{template.name}")
IO.puts("Platform: #{template.platform}")
IO.puts("Background color: #{template.background_color}")
IO.puts("Support email: #{template.support_email}")
IO.puts("Watch count: #{template.watch_count}")
IO.puts("Allow on multiple devices: #{template.allow_on_multiple_devices}")
IO.puts("Issued keys: #{template.issued_keys_count}")
IO.puts("Active keys: #{template.active_keys_count}")
IO.puts("Credential profiles: #{inspect(template.credential_profiles)}")
IO.puts("Landing pages: #{inspect(template.landing_pages)}")
{:ok, %AccessGrid.CardTemplatePair{} = pair} ->
IO.puts("Pair: #{pair.name}")
Enum.each(pair.templates, fn t -> IO.puts(" - #{t.platform}: #{t.id}") end)
end{:ok, events, pagination} = AccessGrid.Console.get_logs("template_id",
page: 1,
per_page: 50,
filters: %{
device: "mobile",
start_date: "2025-01-01T00:00:00Z",
end_date: "2025-01-31T23:59:59Z",
event_type: "access_pass.installed"
}
)
Enum.each(events, fn event ->
IO.puts("#{event.created_at}: #{event.event}")
end)
IO.puts("Page #{pagination["current_page"]} of #{pagination["total_pages"]}")Returns the Apple In-App Provisioning preflight bundle for an access pass.
{:ok, preflight} = AccessGrid.Console.ios_preflight(
"template_id",
%{access_pass_ex_id: "ap_abc123"}
)
IO.puts("Provisioning credential: #{preflight.provisioning_credential_identifier}")
IO.puts("Sharing instance: #{preflight.sharing_instance_identifier}")
IO.puts("Card template: #{preflight.card_template_identifier}")
IO.puts("Environment: #{preflight.environment_identifier}")For Android+SEOS templates, the server also syncs the template to the HID portal. If the sync fails the template rolls back to draft and the call returns {:error, :validation_failed, _}.
{:ok, result} = AccessGrid.Console.publish_template("template_id")
IO.puts("Template #{result.id} status: #{result.status}")
# status is one of: "publishing" (already in flight), "in-review" (Apple
# queued), or "ready" (Android immediate)Fetches the template's SmartTap private key, decrypted client-side. The SDK generates a fresh ephemeral keypair internally, submits the public half, and decrypts the server's response — you get the plaintext PEM back without touching any crypto.
{:ok, reveal} = AccessGrid.Console.reveal_smart_tap("template_id")
IO.puts("Key version: #{reveal.key_version}")
IO.puts("Collector ID: #{reveal.collector_id}")
IO.puts("Fingerprint: #{reveal.fingerprint}")
IO.puts(reveal.private_key) # PEM — store in your reader/collector key vaultThe server enforces single-use on pubkey fingerprint and rate-limits to 1 per minute per account. Retrying within the rate-limit window returns {:error, :rate_limited, _}.
{:ok, pairs, pagination} = AccessGrid.Console.list_card_template_pairs(
page: 1,
per_page: 25
)
Enum.each(pairs, fn pair ->
IO.puts("#{pair.name}: iOS=#{pair.ios_template.id}, Android=#{pair.android_template.id}")
end)Pairs two existing card templates (one Apple, one Android) for cross-platform issuance. Both templates must be status: "ready" and use a compatible protocol combination (both SEOS, or Apple-DESFire + Android-SmartTap).
{:ok, pair} = AccessGrid.Console.create_card_template_pair(%{
name: "Cross-Platform Employee Badge",
apple_card_template_id: "tpl_apple_xyz",
google_card_template_id: "tpl_android_xyz"
})
IO.puts("Pair ID: #{pair.id}"){:ok, pages} = AccessGrid.Console.list_landing_pages()
Enum.each(pages, fn page -> IO.puts("#{page.id}: #{page.name} (#{page.kind})") end){:ok, page} = AccessGrid.Console.create_landing_page(%{
name: "Lobby Access",
kind: "universal",
additional_text: "Welcome — install your pass on your phone",
bg_color: "#1a1a1a",
allow_immediate_download: true,
logo: AccessGrid.Utils.base64_file!("path/to/logo.png")
})
IO.puts("Landing page ID: #{page.id}")
IO.puts("Logo URL: #{page.logo_url}")kind is immutable after creation — passing a different value yields a {:error, :validation_failed, _}. Other fields can be updated freely.
{:ok, page} = AccessGrid.Console.update_landing_page("lp_ex_id_1", %{
name: "Lobby Access (renamed)",
password: "letmein",
is_2fa_enabled: true
}){:ok, profiles} = AccessGrid.Console.list_credential_profiles()
Enum.each(profiles, fn p -> IO.puts("#{p.id}: #{p.name} (aid=#{p.aid})") end)Each app has a fixed required key count: KEY-ID-main and KEY-ID-alt need 2 keys, ag_main needs 3. Passing the wrong number yields {:error, :validation_failed, _}.
{:ok, profile} = AccessGrid.Console.create_credential_profile(%{
name: "Office Reader",
app_name: "KEY-ID-main",
keys: [
%{value: "00112233445566778899AABBCCDDEEFF"},
%{value: "FFEEDDCCBBAA99887766554433221100", keys_diversified: true}
]
})
IO.puts("Profile ID: #{profile.id}")
IO.puts("AID: #{profile.aid}")
IO.inspect(profile.keys, label: "keys")
IO.inspect(profile.files, label: "files"){:ok, webhooks, pagination} = AccessGrid.Console.list_webhooks(page: 1, per_page: 50)
Enum.each(webhooks, fn wh ->
IO.puts("#{wh.id}: #{wh.name} (#{wh.auth_method}) → #{wh.url}")
end)auth_method is either "bearer_token" (default) or "mtls". Sensitive fields appear on the create response only once:
- bearer_token:
private_keyis returned — store it immediately, it cannot be retrieved later. - mtls:
client_cert(PEM) andcert_expires_atare returned.
{:ok, webhook} = AccessGrid.Console.create_webhook(%{
name: "Production",
url: "https://example.com/hooks",
subscribed_events: ["ag.access_pass.issued", "ag.card_template.created"],
auth_method: "bearer_token"
})
IO.puts("Webhook ID: #{webhook.id}")
IO.puts("Private key (store now — not retrievable later): #{webhook.private_key}")Returns :ok (flat, not {:ok, _}) on success since the server returns 204 No Content.
:ok = AccessGrid.Console.delete_webhook("webhook_id"){:ok, orgs} = AccessGrid.Console.list_hid_orgs()
Enum.each(orgs, fn org -> IO.puts("#{org.id}: #{org.name} (status=#{org.status})") end)Idempotent on the derived slug — if an org with the same slug already exists, the server returns the existing record with 200 instead of creating a new one.
{:ok, org} = AccessGrid.Console.create_hid_org(%{
name: "Acme Corp",
full_address: "1 Acme Plaza, NY 10001",
phone: "+1-555-0100",
first_name: "Wile E.",
last_name: "Coyote"
})
IO.puts("HID org ID: #{org.id}")
IO.puts("Slug: #{org.slug}")Completes registration with the HID portal using the org's registered email and the customer's HID portal password.
{:ok, org} = AccessGrid.Console.activate_hid_org(%{
email: "admin@acme.com",
password: "hid-portal-password"
})
IO.puts("Status: #{org.status}")The server may return extra fields (already_completed: true if the org is already activated, job_queued: true if a registration job is in flight) — these aren't surfaced on the struct. Inspect org.status for the current state.
{:ok, items, pagination} = AccessGrid.Console.list_ledger_items(
page: 1,
per_page: 50,
start_date: "2026-01-01T00:00:00Z",
end_date: "2026-12-31T23:59:59Z"
)
Enum.each(items, fn item ->
IO.puts("#{item.created_at}: #{item.kind} $#{item.amount}")
if item.access_pass, do: IO.puts(" pass: #{item.access_pass.full_name}")
end)| Endpoint | Method | Supported |
|---|---|---|
| POST /v1/key-cards | AccessPasses.issue/2 |
Y |
| GET /v1/key-cards/{id} | AccessPasses.get/2 |
Y |
| PATCH /v1/key-cards/{id} | AccessPasses.update/3 |
Y |
| GET /v1/key-cards | AccessPasses.list/2 |
Y |
| POST /v1/key-cards/{id}/suspend | AccessPasses.suspend/2 |
Y |
| POST /v1/key-cards/{id}/resume | AccessPasses.resume/2 |
Y |
| POST /v1/key-cards/{id}/unlink | AccessPasses.unlink/2 |
Y |
| POST /v1/key-cards/{id}/delete | AccessPasses.delete/2 |
Y |
| POST /v1/console/card-templates | Console.create_template/2 |
Y |
| PUT /v1/console/card-templates/{id} | Console.update_template/3 |
Y |
| GET /v1/console/card-templates/{id} | Console.read_template/2 |
Y |
| POST /v1/console/card-templates/{id}/publish | Console.publish_template/2 |
Y |
| POST /v1/console/card-templates/{id}/smart-tap/reveal | Console.reveal_smart_tap/2 |
Y |
| GET /v1/console/card-templates/{id}/logs | Console.get_logs/2 |
Y |
| POST /v1/console/card-templates/{id}/ios_preflight | Console.ios_preflight/3 |
Y |
| GET /v1/console/card-template-pairs | Console.list_card_template_pairs/1 |
Y |
| POST /v1/console/card-template-pairs | Console.create_card_template_pair/2 |
Y |
| GET /v1/console/landing-pages | Console.list_landing_pages/1 |
Y |
| POST /v1/console/landing-pages | Console.create_landing_page/2 |
Y |
| PUT /v1/console/landing-pages/{id} | Console.update_landing_page/3 |
Y |
| GET /v1/console/credential-profiles | Console.list_credential_profiles/1 |
Y |
| POST /v1/console/credential-profiles | Console.create_credential_profile/2 |
Y |
| GET /v1/console/webhooks | Console.list_webhooks/1 |
Y |
| POST /v1/console/webhooks | Console.create_webhook/2 |
Y |
| DELETE /v1/console/webhooks/{id} | Console.delete_webhook/2 |
Y |
| POST /v1/console/hid/orgs | Console.create_hid_org/2 |
Y |
| POST /v1/console/hid/orgs/activate | Console.activate_hid_org/2 |
Y |
| GET /v1/console/hid/orgs | Console.list_hid_orgs/1 |
Y |
| GET /v1/console/ledger-items | Console.list_ledger_items/1 |
Y |
AccessGrid.Utils.base64_file!/1 reads a file from the local filesystem and returns its contents Base64-encoded as a string — suitable for any of the SDK's image-accepting params (background, logo, icon on create_template; logo on create_landing_page; employee_photo on AccessPasses.issue).
# Bang variant — raises File.Error if the path doesn't exist
b64 = AccessGrid.Utils.base64_file!("path/to/badge.png")
# Tuple variant — returns {:ok, encoded} or {:error, posix_reason}
case AccessGrid.Utils.base64_file("path/to/badge.png") do
{:ok, b64} -> # ...
{:error, :enoent} -> # file missing
end
The helper does not validate the file's contents — the server enforces format and size limits (PNG/JPEG, 10MB max) and returns clear errors. No URL support: if you have an image at a URL, fetch it with your own HTTP client and Base.encode64/1 the bytes.
All functions return {:ok, result} (or {:ok, list, pagination} for paginated lists, or :ok for delete_webhook) on success, or {:error, reason, failure} on failure:
case AccessGrid.AccessPasses.get("card_id") do
{:ok, card} ->
IO.puts("Found card: #{card.full_name}")
{:error, :not_found, _failure} ->
IO.puts("Card not found")
{:error, :unauthorized, _failure} ->
IO.puts("Invalid credentials")
{:error, :validation_failed, failure} ->
IO.puts("Validation error: #{inspect(failure.body_decoded)}")
{:error, reason, _failure} ->
IO.puts("Request failed: #{reason}")
endError reasons include:
:unauthorized- Invalid credentials (401):forbidden- Access denied (403):not_found- Resource not found (404):conflict- Conflict with current resource state (409) — e.g.reveal_smart_tapretried with a pubkey that's already been used:validation_failed- Invalid parameters (422):rate_limited- Too many requests (429):timeout- Request timeout:server_error- Server error (5xx):request_failed- Other failures:missing_required- Local validation caught a missing/blank required field before any HTTP call. The third element is a non-empty list of atom field names (e.g.[:template_id, :access_pass_ex_id]) — not anHttpFailure. SeeAccessGrid.Params.
The third element (failure) is an AccessGrid.HttpFailure struct with additional context like status code and response body, except for :missing_required (see above — that variant carries a list of field-name atoms instead).
See the Testing Guide for detailed examples of how to mock AccessGrid in your tests.
The SDK automatically handles:
- Request signing using HMAC-SHA256
- Secure payload encoding
- Authentication headers
- HTTPS communication
Never expose your api_secret in client-side code. Always use environment variables or a secure configuration management system.
Bug reports and pull requests are welcome on GitHub.
- Elixir 1.17 or higher
- OTP 26 or higher
Note: A
.tool-versionsfile exists.asdfusers can install these requirements withasdf installfrom the project root.
After checking out the repo, run the doctor script to verify your environment:
bin/dev/doctorRun tests:
mix testRun all checks (format, credo, dialyzer):
bin/dev/auditThe package is available as open source under the terms of the MIT License.
