+1 (415) 843-4662

Writing your own Capacitor plugin: Swift, Kotlin and a typed TypeScript API

Most Ionic teams reach a point where the plugin ecosystem stops helping. You need an SDK that only ships as a native library, a hardware integration (a payment terminal, a label printer, a BLE medical device), or a background behaviour that no @capacitor/* package exposes. At that point writing your own plugin is not exotic — it is a couple of hundred lines of code and the cleanest boundary you can put between your web app and native platform code.

This tutorial builds a complete plugin end to end: a typed TypeScript API, a Swift implementation, a Kotlin implementation, a runtime permission flow, event listeners, a web fallback so ionic serve keeps working, tests, and private distribution inside your organisation. The example is a DeviceAttestation-style plugin — a small surface that still exercises every part of the plugin contract — but the shape applies to any native SDK you need to wrap.

When to write a plugin (and when not to)

Before you open Xcode, rule out the cheaper options:

  • A JS-only wrapper is enough. If the capability exists in the WebView (fetch, Web Crypto, geolocation, IndexedDB, file pickers), skip native code.
  • An existing plugin is 90% right. Forking a maintained plugin and adding one method is usually less long-term work than a new package.
  • The behaviour must survive app termination. Background work is a native problem; that is a plugin.

Write a plugin when you need a native SDK, a native permission, a native background lifecycle, or performance that a bridge round-trip per item would ruin. Do not write a plugin to "tidy up" JavaScript.

1. Scaffold

Capacitor ships a generator. From an empty directory next to your app repo:

npm init @capacitor/plugin@latest
# name: @acme/capacitor-device-attestation
# package id: com.acme.plugins.deviceattestation
# class name: DeviceAttestation

You get a small monorepo-friendly package:

src/definitions.ts     # the public typed API
src/index.ts           # registerPlugin() call
src/web.ts             # browser implementation / fallback
ios/Sources/...        # Swift: Plugin.swift + implementation
android/src/main/...   # Kotlin: Plugin.kt + implementation

Keep the generated split between Plugin and implementation classes. The plugin class only translates between the bridge and plain native types; the implementation class holds logic and is the part you can unit test without a bridge.

2. Design the TypeScript API first

The interface is the contract your app team codes against, so write it before any native code. Two rules save weeks: everything crossing the bridge must be JSON-serialisable, and every call must be able to fail.

// src/definitions.ts
export type AttestationLevel = 'hardware' | 'software' | 'unavailable';

export interface AttestationResult {
  /** Opaque token to send to your backend for verification. */
  token: string;
  level: AttestationLevel;
  /** Epoch millis; the token is short-lived. */
  expiresAt: number;
}

export interface PermissionStatus {
  attestation: 'granted' | 'denied' | 'prompt';
}

export interface DeviceAttestationPlugin {
  /** Returns a fresh attestation token. Rejects with code 'unsupported' on old OS versions. */
  attest(options: { nonce: string }): Promise<AttestationResult>;
  checkPermissions(): Promise<PermissionStatus>;
  requestPermissions(): Promise<PermissionStatus>;
  addListener(
    eventName: 'integrityChanged',
    listener: (event: { level: AttestationLevel }) => void,
  ): Promise<{ remove: () => Promise<void> }>;
  removeAllListeners(): Promise<void>;
}

Things deliberately avoided: no Date objects (send epoch millis), no Uint8Array (base64 strings), no callbacks that expect to be called more than once outside of addListener, no methods that return void when they can fail. Bridge payloads also should not carry megabytes — pass a file URI, not file contents.

src/index.ts wires the registration and picks the web implementation lazily:

import { registerPlugin } from '@capacitor/core';
import type { DeviceAttestationPlugin } from './definitions';

const DeviceAttestation = registerPlugin<DeviceAttestationPlugin>('DeviceAttestation', {
  web: () => import('./web').then(m => new m.DeviceAttestationWeb()),
});

export * from './definitions';
export { DeviceAttestation };

3. iOS: Swift

Capacitor 6 introduced, and 7/8 standardised, the typed method registration via CAPPlugin + CAPBridgedPlugin. Declaring pluginMethods is what lets the bridge validate calls instead of failing silently.

// ios/Sources/DeviceAttestationPlugin/DeviceAttestationPlugin.swift
import Foundation
import Capacitor

@objc(DeviceAttestationPlugin)
public class DeviceAttestationPlugin: CAPPlugin, CAPBridgedPlugin {
    public let identifier = "DeviceAttestationPlugin"
    public let jsName = "DeviceAttestation"
    public let pluginMethods: [CAPPluginMethod] = [
        CAPPluginMethod(name: "attest", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "checkPermissions", returnType: CAPPluginReturnPromise),
        CAPPluginMethod(name: "requestPermissions", returnType: CAPPluginReturnPromise)
    ]

    private let impl = DeviceAttestation()

    @objc func attest(_ call: CAPPluginCall) {
        guard let nonce = call.getString("nonce"), !nonce.isEmpty else {
            call.reject("nonce is required", "invalid_argument")
            return
        }

        impl.attest(nonce: nonce) { result in
            switch result {
            case .success(let attestation):
                call.resolve([
                    "token": attestation.token,
                    "level": attestation.level,
                    "expiresAt": attestation.expiresAt
                ])
            case .failure(let error):
                call.reject(error.localizedDescription, error.code)
            }
        }
    }

    @objc override public func checkPermissions(_ call: CAPPluginCall) {
        call.resolve(["attestation": impl.isSupported ? "granted" : "denied"])
    }

    @objc override public func requestPermissions(_ call: CAPPluginCall) {
        checkPermissions(call)
    }

    /// Called from the implementation when the OS reports an integrity change.
    func emitIntegrityChanged(level: String) {
        notifyListeners("integrityChanged", data: ["level": level])
    }
}

Four Swift details that bite teams:

  1. Resolve or reject exactly once. A path that does neither leaves a JavaScript promise pending forever — the single most common custom-plugin bug we are called in to fix.
  2. call.keepAlive(true) is required only for calls that emit more than once; use listeners instead where you can.
  3. Hop to the main thread (DispatchQueue.main.async) before touching UIKit — presenting a view controller from a background queue crashes.
  4. Guard OS versions in the implementation, not the plugin class, and reject with a stable code (unsupported) so the app can branch.

The podspec/SPM manifest generated for you already points at ios/Sources; if you add a third-party dependency, add it in both Package.swift and the .podspec, because consumer apps may use either.

4. Android: Kotlin

// android/src/main/java/com/acme/plugins/deviceattestation/DeviceAttestationPlugin.kt
package com.acme.plugins.deviceattestation

import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import com.getcapacitor.annotation.Permission
import com.getcapacitor.annotation.PermissionCallback

@CapacitorPlugin(
    name = "DeviceAttestation",
    permissions = [Permission(alias = "attestation", strings = [android.Manifest.permission.INTERNET])]
)
class DeviceAttestationPlugin : Plugin() {

    private val impl = DeviceAttestation()

    @PluginMethod
    fun attest(call: PluginCall) {
        val nonce = call.getString("nonce")
        if (nonce.isNullOrEmpty()) {
            call.reject("nonce is required", "invalid_argument")
            return
        }

        if (getPermissionState("attestation") != com.getcapacitor.PermissionState.GRANTED) {
            requestPermissionForAlias("attestation", call, "attestPermsCallback")
            return
        }

        runAttestation(call, nonce)
    }

    @PermissionCallback
    private fun attestPermsCallback(call: PluginCall) {
        val nonce = call.getString("nonce") ?: return call.reject("nonce is required", "invalid_argument")
        if (getPermissionState("attestation") == com.getcapacitor.PermissionState.GRANTED) {
            runAttestation(call, nonce)
        } else {
            call.reject("Permission denied", "permission_denied")
        }
    }

    private fun runAttestation(call: PluginCall, nonce: String) {
        impl.attest(context, nonce) { result ->
            result.fold(
                onSuccess = { attestation ->
                    val ret = JSObject().apply {
                        put("token", attestation.token)
                        put("level", attestation.level)
                        put("expiresAt", attestation.expiresAt)
                    }
                    call.resolve(ret)
                },
                onFailure = { error -> call.reject(error.message, "attestation_failed") },
            )
        }
    }

    fun emitIntegrityChanged(level: String) {
        notifyListeners("integrityChanged", JSObject().put("level", level))
    }
}

Android notes for 2026 builds:

  • Declare permissions in the @CapacitorPlugin annotation and use requestPermissionForAlias — hand-rolled onRequestPermissionsResult handling breaks when several plugins ask at once.
  • Your plugin's android/build.gradle must stay compatible with the app's compileSdk/targetSdk (36 for current Play requirements) and with 16 KB page sizes if you bundle native .so files: build against NDK r27+ and verify with check_elf_alignment.sh.
  • Anything long-running belongs in a coroutine on Dispatchers.IO; the bridge call arrives on the main thread.
  • If you need an Activity result, use @ActivityCallback and startActivityForResult(call, intent, "callbackName") so Capacitor can re-associate the saved call after process death.

5. The web fallback

A web implementation is not optional in practice: without it, ionic serve, your Playwright suite and your PWA build all throw on import.

// src/web.ts
import { WebPlugin } from '@capacitor/core';
import type { DeviceAttestationPlugin, PermissionStatus, AttestationResult } from './definitions';

export class DeviceAttestationWeb extends WebPlugin implements DeviceAttestationPlugin {
  async attest(): Promise<AttestationResult> {
    if (import.meta.env?.DEV) {
      return { token: 'dev-token', level: 'unavailable', expiresAt: Date.now() + 300_000 };
    }
    throw this.unavailable('Device attestation is not available in the browser.');
  }

  async checkPermissions(): Promise<PermissionStatus> {
    return { attestation: 'denied' };
  }

  async requestPermissions(): Promise<PermissionStatus> {
    return { attestation: 'denied' };
  }
}

this.unavailable() produces an error the app can detect (err.code === 'UNAVAILABLE'), which is much friendlier than a thrown TypeError about an undefined method.

6. Using it from the Ionic app

import { Capacitor } from '@capacitor/core';
import { DeviceAttestation } from '@acme/capacitor-device-attestation';

export async function attestedFetch(path: string, init?: RequestInit) {
  let header: Record<string, string> = {};

  if (Capacitor.isNativePlatform()) {
    const nonce = crypto.randomUUID();
    try {
      const { token } = await DeviceAttestation.attest({ nonce });
      header = { 'X-Attestation': token, 'X-Attestation-Nonce': nonce };
    } catch (err: any) {
      if (err?.code !== 'unsupported') throw err;   // old OS: continue unattested
    }
  }

  return fetch(path, { ...init, headers: { ...init?.headers, ...header } });
}

Wrap the plugin in one app-level service like this rather than calling it from components. When the native API changes, you edit one file, and your component tests can mock the service instead of the bridge.

Listeners follow the standard shape and must be removed:

const handle = await DeviceAttestation.addListener('integrityChanged', ({ level }) => {
  console.log('integrity now', level);
});
// Angular ngOnDestroy / React useEffect cleanup / Vue onUnmounted:
await handle.remove();

7. Testing

Three layers, cheapest first:

  • TypeScript: unit-test your app service with the plugin mocked. In Vitest, vi.mock('@acme/capacitor-device-attestation', () => ({ DeviceAttestation: { attest: vi.fn() } })).
  • Swift: test the implementation class with XCTest — it has no CAPPluginCall dependency, which is exactly why the generator splits the classes.
  • Kotlin: JUnit + Robolectric for the implementation; instrumented tests only for the permission flow.

Then run the plugin inside a throwaway Ionic app on a real device. Simulators lie about hardware-backed keys, camera hardware, BLE and background execution.

8. Shipping it privately

Most client plugins are internal, not open source. Two workable options:

  1. Private npm scope (@acme) on npm, GitHub Packages or your Artifactory. Consumers add an .npmrc token; npx cap sync then treats it like any other plugin.
  2. Monorepo path dependency: keep the plugin in packages/ and reference it as "@acme/capacitor-device-attestation": "workspace:*". Simplest for a single app; painful once three apps consume it.

Either way, publish a compiled dist/ (the generated npm run build runs Rollup + tsc), keep a CHANGELOG, and pin the peer dependency on @capacitor/core to the major version you tested. When you bump Capacitor majors in the app, bump and re-release the plugin in the same PR — mismatched majors are the second most common failure mode after unresolved promises.

Checklist before you call it done

  • Every code path resolves or rejects exactly once, with a stable error code.
  • Web implementation returns unavailable() instead of crashing.
  • Permissions declared in @CapacitorPlugin and Info.plist usage strings written.
  • Main-thread rules respected on both platforms.
  • No large payloads over the bridge; URIs and base64 only where small.
  • Listeners documented as needing remove().
  • pluginMethods list matches the TypeScript interface exactly.
  • Builds against current compileSdk/Xcode and passes 16 KB alignment checks.
  • Tested on a physical device on both platforms, including a cold start.

Need this done once, properly?

Custom plugins are where hybrid projects quietly acquire native debt: one unresolved promise or a main-thread violation and you are debugging Xcode stack traces instead of shipping features. HybridMob's senior Ionic and Capacitor consultants have wrapped payment terminals, BLE hardware, SDKs with no hybrid support and background services for clients across the last decade, and we hand the package over with tests and a release process your team owns. Tell us what you need to wrap and we will scope it.