diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java
index 5ae366c2e867c7a01930de133d77ccbd3fe5df8e..f131a1d2f4b7337a9a6779b4d22bd5ec33740d99 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/ContactManager.java
@@ -59,17 +59,18 @@ public interface ContactManager {
 			throws DbException;
 
 	/**
-	 * Returns the static link that needs to be sent to the contact to be added.
+	 * Returns the handshake link that needs to be sent to a contact we want
+	 * to add.
 	 */
 	String getHandshakeLink() throws DbException;
 
 	/**
-	 * Requests a new contact to be added via the given {@code link}.
+	 * Adds a new pending contact identified by the given handshake link.
 	 *
-	 * @param link The link received from the contact we want to add.
+	 * @param link The handshake link received from the contact we want to add.
 	 * @param alias The alias the user has given this contact.
 	 */
-	void addPendingContact(String link, String alias)
+	PendingContact addPendingContact(String link, String alias)
 			throws DbException, FormatException;
 
 	/**
@@ -78,10 +79,9 @@ public interface ContactManager {
 	Collection<PendingContact> getPendingContacts() throws DbException;
 
 	/**
-	 * Removes a {@link PendingContact} that is in state
-	 * {@link PendingContactState FAILED}.
+	 * Removes a {@link PendingContact}.
 	 */
-	void removePendingContact(PendingContactId pendingContact) throws DbException;
+	void removePendingContact(PendingContactId p) throws DbException;
 
 	/**
 	 * Returns the contact with the given ID.
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/contact/HandshakeLinkConstants.java b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/HandshakeLinkConstants.java
new file mode 100644
index 0000000000000000000000000000000000000000..b852c27c044b221c1c2b3e16b433d9102e89e5dd
--- /dev/null
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/contact/HandshakeLinkConstants.java
@@ -0,0 +1,34 @@
+package org.briarproject.bramble.api.contact;
+
+import java.util.regex.Pattern;
+
+public interface HandshakeLinkConstants {
+
+	/**
+	 * The current version of the handshake link format.
+	 */
+	int FORMAT_VERSION = 0;
+
+	/**
+	 * The length of a base32-encoded handshake link in bytes, excluding the
+	 * 'briar://' prefix.
+	 */
+	int BASE32_LINK_BYTES = 53;
+
+	/**
+	 * The length of a raw handshake link in bytes, before base32 encoding.
+	 */
+	int RAW_LINK_BYTES = 33;
+
+	/**
+	 * Regular expression for matching handshake links, including or excluding
+	 * the 'briar://' prefix.
+	 */
+	Pattern LINK_REGEX =
+			Pattern.compile("(briar://)?([a-z2-7]{" + BASE32_LINK_BYTES + "})");
+
+	/**
+	 * Label for hashing handshake public keys to calculate their identifiers.
+	 */
+	String ID_LABEL = "org.briarproject.bramble/HANDSHAKE_KEY_ID";
+}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/util/Base32.java b/bramble-api/src/main/java/org/briarproject/bramble/util/Base32.java
index cff5c4e9901db9a20f3d8d54c49487f76ec4a392..d15292b9208a9d274852308e5ffbcadc429a2898 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/util/Base32.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/util/Base32.java
@@ -37,7 +37,7 @@ public class Base32 {
 		return s.toString();
 	}
 
-	public static byte[] decode(String s) {
+	public static byte[] decode(String s, boolean strict) {
 		ByteArrayOutputStream b = new ByteArrayOutputStream();
 		int digitIndex = 0, digitCount = s.length(), currentByte = 0x00;
 		int byteMask = 0x80, codeMask = 0x10;
@@ -61,7 +61,7 @@ public class Base32 {
 			}
 		}
 		// If any extra bits were used for encoding, they should all be zero
-		if (byteMask != 0x80 && currentByte != 0x00)
+		if (strict && byteMask != 0x80 && currentByte != 0x00)
 			throw new IllegalArgumentException();
 		return b.toByteArray();
 	}
diff --git a/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java b/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java
index b13fb6030665d260ad7b4cf24b38028035619d89..9b6b1220c76d8beca91a76069b964456b36120c6 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/util/StringUtils.java
@@ -153,4 +153,13 @@ public class StringUtils {
 		return new String(c);
 	}
 
+	public static String getRandomBase32String(int length) {
+		char[] c = new char[length];
+		for (int i = 0; i < length; i++) {
+			int character = random.nextInt(32);
+			if (character < 26) c[i] = (char) ('a' + character);
+			else c[i] = (char) ('2' + (character - 26));
+		}
+		return new String(c);
+	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
index 2cf100a1415c2f53794dfcc11e35775c1edf4655..632d9226ba57598eca141d2a772e4b7af750db2d 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactManagerImpl.java
@@ -1,5 +1,6 @@
 package org.briarproject.bramble.contact;
 
+import org.briarproject.bramble.api.FormatException;
 import org.briarproject.bramble.api.contact.Contact;
 import org.briarproject.bramble.api.contact.ContactId;
 import org.briarproject.bramble.api.contact.ContactManager;
@@ -20,19 +21,19 @@ import org.briarproject.bramble.api.transport.KeyManager;
 
 import java.util.Collection;
 import java.util.List;
-import java.util.Random;
 import java.util.concurrent.CopyOnWriteArrayList;
 
 import javax.annotation.Nullable;
 import javax.annotation.concurrent.ThreadSafe;
 import javax.inject.Inject;
 
-import static java.util.Collections.emptyList;
+import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.BASE32_LINK_BYTES;
 import static org.briarproject.bramble.api.identity.AuthorConstants.MAX_AUTHOR_NAME_LENGTH;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.OURSELVES;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.UNKNOWN;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.UNVERIFIED;
 import static org.briarproject.bramble.api.identity.AuthorInfo.Status.VERIFIED;
+import static org.briarproject.bramble.util.StringUtils.getRandomBase32String;
 import static org.briarproject.bramble.util.StringUtils.toUtf8;
 
 @ThreadSafe
@@ -40,19 +41,22 @@ import static org.briarproject.bramble.util.StringUtils.toUtf8;
 class ContactManagerImpl implements ContactManager {
 
 	private static final String REMOTE_CONTACT_LINK =
-			"briar://" + getRandomBase32String(LINK_LENGTH);
+			"briar://" + getRandomBase32String(BASE32_LINK_BYTES);
 
 	private final DatabaseComponent db;
 	private final KeyManager keyManager;
 	private final IdentityManager identityManager;
+	private final PendingContactFactory pendingContactFactory;
 	private final List<ContactHook> hooks;
 
 	@Inject
 	ContactManagerImpl(DatabaseComponent db, KeyManager keyManager,
-			IdentityManager identityManager) {
+			IdentityManager identityManager,
+			PendingContactFactory pendingContactFactory) {
 		this.db = db;
 		this.keyManager = keyManager;
 		this.identityManager = identityManager;
+		this.pendingContactFactory = pendingContactFactory;
 		hooks = new CopyOnWriteArrayList<>();
 	}
 
@@ -96,34 +100,23 @@ class ContactManagerImpl implements ContactManager {
 		return REMOTE_CONTACT_LINK;
 	}
 
-	// TODO replace with real implementation
-	@SuppressWarnings("SameParameterValue")
-	private static String getRandomBase32String(int length) {
-		Random random = new Random();
-		char[] c = new char[length];
-		for (int i = 0; i < length; i++) {
-			int character = random.nextInt(32);
-			if (character < 26) c[i] = (char) ('a' + character);
-			else c[i] = (char) ('2' + (character - 26));
-		}
-		return new String(c);
-	}
-
 	@Override
-	public void addPendingContact(String link, String alias)
-			throws DbException {
-		// TODO replace with real implementation
+	public PendingContact addPendingContact(String link, String alias)
+			throws DbException, FormatException {
+		PendingContact p =
+				pendingContactFactory.createPendingContact(link, alias);
+		db.transaction(false, txn -> db.addPendingContact(txn, p));
+		return p;
 	}
 
 	@Override
-	public Collection<PendingContact> getPendingContacts() {
-		// TODO replace with real implementation
-		return emptyList();
+	public Collection<PendingContact> getPendingContacts() throws DbException {
+		return db.transactionWithResult(true, db::getPendingContacts);
 	}
 
 	@Override
-	public void removePendingContact(PendingContactId id) throws DbException {
-		// TODO replace with real implementation
+	public void removePendingContact(PendingContactId p) throws DbException {
+		db.transaction(false, txn -> db.removePendingContact(txn, p));
 	}
 
 	@Override
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
index 2cb610972caac67f87f197aaafd15016fe7472d7..f31ce108aa08860ca63cebcdee5fc6eef712edd4 100644
--- a/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/ContactModule.java
@@ -28,4 +28,10 @@ public class ContactModule {
 			ContactExchangeTaskImpl contactExchangeTask) {
 		return contactExchangeTask;
 	}
+
+	@Provides
+	PendingContactFactory providePendingContactFactory(
+			PendingContactFactoryImpl pendingContactFactory) {
+		return pendingContactFactory;
+	}
 }
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/PendingContactFactory.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/PendingContactFactory.java
new file mode 100644
index 0000000000000000000000000000000000000000..eab17d59d5cf21443fb006315ddd79ac66e59f78
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/PendingContactFactory.java
@@ -0,0 +1,10 @@
+package org.briarproject.bramble.contact;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.contact.PendingContact;
+
+interface PendingContactFactory {
+
+	PendingContact createPendingContact(String link, String alias)
+			throws FormatException;
+}
diff --git a/bramble-core/src/main/java/org/briarproject/bramble/contact/PendingContactFactoryImpl.java b/bramble-core/src/main/java/org/briarproject/bramble/contact/PendingContactFactoryImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..36a627013c369c649ed873bb7541f1137a66c856
--- /dev/null
+++ b/bramble-core/src/main/java/org/briarproject/bramble/contact/PendingContactFactoryImpl.java
@@ -0,0 +1,67 @@
+package org.briarproject.bramble.contact;
+
+import org.briarproject.bramble.api.FormatException;
+import org.briarproject.bramble.api.contact.PendingContact;
+import org.briarproject.bramble.api.contact.PendingContactId;
+import org.briarproject.bramble.api.contact.PendingContactState;
+import org.briarproject.bramble.api.crypto.CryptoComponent;
+import org.briarproject.bramble.api.crypto.KeyParser;
+import org.briarproject.bramble.api.crypto.PublicKey;
+import org.briarproject.bramble.api.system.Clock;
+import org.briarproject.bramble.util.Base32;
+
+import java.security.GeneralSecurityException;
+import java.util.regex.Matcher;
+
+import javax.inject.Inject;
+
+import static java.lang.System.arraycopy;
+import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.FORMAT_VERSION;
+import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.ID_LABEL;
+import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.LINK_REGEX;
+import static org.briarproject.bramble.api.contact.HandshakeLinkConstants.RAW_LINK_BYTES;
+
+class PendingContactFactoryImpl implements PendingContactFactory {
+
+	private final CryptoComponent crypto;
+	private final Clock clock;
+
+	@Inject
+	PendingContactFactoryImpl(CryptoComponent crypto, Clock clock) {
+		this.crypto = crypto;
+		this.clock = clock;
+	}
+
+	@Override
+	public PendingContact createPendingContact(String link, String alias)
+			throws FormatException {
+		PublicKey publicKey = parseHandshakeLink(link);
+		PendingContactId id = getPendingContactId(publicKey);
+		long timestamp = clock.currentTimeMillis();
+		return new PendingContact(id, publicKey.getEncoded(), alias,
+				PendingContactState.WAITING_FOR_CONNECTION, timestamp);
+	}
+
+	private PublicKey parseHandshakeLink(String link) throws FormatException {
+		Matcher matcher = LINK_REGEX.matcher(link);
+		if (!matcher.matches()) throw new FormatException();
+		link = matcher.group(); // Discard anything before or after the link
+		if (link.startsWith("briar://")) link = link.substring(8);
+		byte[] base32 = Base32.decode(link, false);
+		if (base32.length != RAW_LINK_BYTES) throw new AssertionError();
+		if (base32[0] != FORMAT_VERSION) throw new FormatException();
+		byte[] publicKeyBytes = new byte[base32.length - 1];
+		arraycopy(base32, 1, publicKeyBytes, 0, publicKeyBytes.length);
+		try {
+			KeyParser parser = crypto.getAgreementKeyParser();
+			return parser.parsePublicKey(publicKeyBytes);
+		} catch (GeneralSecurityException e) {
+			throw new FormatException();
+		}
+	}
+
+	private PendingContactId getPendingContactId(PublicKey publicKey) {
+		byte[] hash = crypto.hash(ID_LABEL, publicKey.getEncoded());
+		return new PendingContactId(hash);
+	}
+}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
index d1e2692641c7575f8bab95d714d6b86f411af1b3..df7463983de22b6cb8f304cc65d743cef5dbb4f1 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/contact/ContactManagerImplTest.java
@@ -47,6 +47,8 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	private final KeyManager keyManager = context.mock(KeyManager.class);
 	private final IdentityManager identityManager =
 			context.mock(IdentityManager.class);
+	private final PendingContactFactory pendingContactFactory =
+			context.mock(PendingContactFactory.class);
 	private final ContactManager contactManager;
 	private final Author remote = getAuthor();
 	private final LocalAuthor localAuthor = getLocalAuthor();
@@ -56,8 +58,8 @@ public class ContactManagerImplTest extends BrambleMockTestCase {
 	private final ContactId contactId = contact.getId();
 
 	public ContactManagerImplTest() {
-		contactManager =
-				new ContactManagerImpl(db, keyManager, identityManager);
+		contactManager = new ContactManagerImpl(db, keyManager,
+				identityManager, pendingContactFactory);
 	}
 
 	@Test